Improve XML documentation coverage across core server components and refresh checker reports.
This commit is contained in:
@@ -10,14 +10,49 @@ public sealed class Account : IDisposable
|
||||
public const string SystemAccountName = "$SYS";
|
||||
public const string ClientInfoHdr = "Nats-Request-Info";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logical account name used for tenant isolation and subject scoping.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the subscription index for this account's subject interest.
|
||||
/// </summary>
|
||||
public SubList SubList { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets default publish/subscribe permissions applied to new clients in this account.
|
||||
/// </summary>
|
||||
public Permissions? DefaultPermissions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum concurrent client connections for this account; `0` means unlimited.
|
||||
/// </summary>
|
||||
public int MaxConnections { get; set; } // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum subscriptions allowed for this account; `0` means unlimited.
|
||||
/// </summary>
|
||||
public int MaxSubscriptions { get; set; } // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Gets the export configuration (services/streams) this account exposes to other accounts.
|
||||
/// </summary>
|
||||
public ExportMap Exports { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the import configuration (services/streams) this account consumes from other accounts.
|
||||
/// </summary>
|
||||
public ImportMap Imports { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the legacy maximum number of JetStream streams; `0` means unlimited.
|
||||
/// </summary>
|
||||
public int MaxJetStreamStreams { get; set; } // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the assigned JetStream resource tier name for policy-driven limits.
|
||||
/// </summary>
|
||||
public string? JetStreamTier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -31,8 +66,19 @@ public sealed class Account : IDisposable
|
||||
public AccountLimits JetStreamLimits { get; set; } = AccountLimits.Unlimited;
|
||||
|
||||
// JWT fields
|
||||
/// <summary>
|
||||
/// Gets or sets the account NKey identity from JWT/account configuration.
|
||||
/// </summary>
|
||||
public string? Nkey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the issuer key that signed account claims for this account.
|
||||
/// </summary>
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets signing keys trusted for delegated account claim updates.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -40,6 +86,11 @@ public sealed class Account : IDisposable
|
||||
/// <remarks>Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934).</remarks>
|
||||
private const string GlobalRevocationKey = "*";
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a user NKey at or before a specified issued-at timestamp.
|
||||
/// </summary>
|
||||
/// <param name="userNkey">User NKey to revoke.</param>
|
||||
/// <param name="issuedAt">Maximum issued-at timestamp (Unix seconds) that is still considered revoked.</param>
|
||||
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +99,14 @@ public sealed class Account : IDisposable
|
||||
/// up to the given timestamp.
|
||||
/// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887).
|
||||
/// </summary>
|
||||
/// <param name="issuedBefore">JWT issued-at cutoff (Unix seconds) for global revocation.</param>
|
||||
public void RevokeAllUsers(long issuedBefore) => _revokedUsers[GlobalRevocationKey] = issuedBefore;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user token is revoked either directly or by global revocation.
|
||||
/// </summary>
|
||||
/// <param name="userNkey">User NKey being evaluated.</param>
|
||||
/// <param name="issuedAt">JWT issued-at timestamp (Unix seconds) to compare against revocation cutoffs.</param>
|
||||
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||
{
|
||||
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||
@@ -74,6 +131,7 @@ public sealed class Account : IDisposable
|
||||
/// Removes the revocation entry for <paramref name="userNkey"/>.
|
||||
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||
/// </summary>
|
||||
/// <param name="userNkey">User NKey whose revocation record should be removed.</param>
|
||||
public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _);
|
||||
|
||||
/// <summary>Removes all revocation entries, including any global ("*") revocation.</summary>
|
||||
@@ -89,18 +147,42 @@ public sealed class Account : IDisposable
|
||||
private int _consumerCount;
|
||||
private long _storageUsed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an account namespace for isolated subscriptions, imports, and exports.
|
||||
/// </summary>
|
||||
/// <param name="name">Unique account name.</param>
|
||||
public Account(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of currently connected clients in this account.
|
||||
/// </summary>
|
||||
public int ClientCount => _clients.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active subscriptions tracked for this account.
|
||||
/// </summary>
|
||||
public int SubscriptionCount => Volatile.Read(ref _subscriptionCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reserved JetStream stream slots for this account.
|
||||
/// </summary>
|
||||
public int JetStreamStreamCount => Volatile.Read(ref _jetStreamStreamCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reserved JetStream consumer slots for this account.
|
||||
/// </summary>
|
||||
public int ConsumerCount => Volatile.Read(ref _consumerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracked JetStream storage usage in bytes for this account.
|
||||
/// </summary>
|
||||
public long StorageUsed => Interlocked.Read(ref _storageUsed);
|
||||
|
||||
/// <summary>Returns false if max connections exceeded.</summary>
|
||||
/// <param name="clientId">Client identifier to register in this account.</param>
|
||||
public bool AddClient(ulong clientId)
|
||||
{
|
||||
if (MaxConnections > 0 && _clients.Count >= MaxConnections)
|
||||
@@ -109,8 +191,15 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client connection from this account's active client set.
|
||||
/// </summary>
|
||||
/// <param name="clientId">Client identifier to remove.</param>
|
||||
public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to increment the subscription count while honoring account limits.
|
||||
/// </summary>
|
||||
public bool IncrementSubscriptions()
|
||||
{
|
||||
if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
|
||||
@@ -119,6 +208,9 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the subscription count after an unsubscribe/removal.
|
||||
/// </summary>
|
||||
public void DecrementSubscriptions()
|
||||
{
|
||||
Interlocked.Decrement(ref _subscriptionCount);
|
||||
@@ -141,6 +233,9 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases one previously reserved JetStream stream slot.
|
||||
/// </summary>
|
||||
public void ReleaseStream()
|
||||
{
|
||||
if (Volatile.Read(ref _jetStreamStreamCount) == 0)
|
||||
@@ -160,6 +255,9 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases one previously reserved JetStream consumer slot.
|
||||
/// </summary>
|
||||
public void ReleaseConsumer()
|
||||
{
|
||||
if (Volatile.Read(ref _consumerCount) == 0)
|
||||
@@ -173,6 +271,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns false if the positive delta would exceed <see cref="AccountLimits.MaxStorage"/>.
|
||||
/// A negative delta always succeeds.
|
||||
/// </summary>
|
||||
/// <param name="deltaBytes">Signed byte delta to apply to tracked storage usage.</param>
|
||||
public bool TrackStorageDelta(long deltaBytes)
|
||||
{
|
||||
var maxStorage = JetStreamLimits.MaxStorage;
|
||||
@@ -193,6 +292,9 @@ public sealed class Account : IDisposable
|
||||
// Reference: Go server/accounts.go — account generation tracking for permission invalidation.
|
||||
private long _generationId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the permission-generation value used to invalidate per-client caches.
|
||||
/// </summary>
|
||||
public long GenerationId => Interlocked.Read(ref _generationId);
|
||||
|
||||
/// <summary>Increments the generation counter, signalling that permission caches are stale.</summary>
|
||||
@@ -202,10 +304,19 @@ public sealed class Account : IDisposable
|
||||
// Go reference: server/client.go — handleSlowConsumer, markConnAsSlow, server/accounts.go slowConsumerCount
|
||||
private long _slowConsumerCount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of clients marked as slow consumers in this account.
|
||||
/// </summary>
|
||||
public long SlowConsumerCount => Interlocked.Read(ref _slowConsumerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Increments the slow-consumer counter for this account.
|
||||
/// </summary>
|
||||
public void IncrementSlowConsumers() => Interlocked.Increment(ref _slowConsumerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Resets the slow-consumer counter to zero.
|
||||
/// </summary>
|
||||
public void ResetSlowConsumerCount() => Interlocked.Exchange(ref _slowConsumerCount, 0L);
|
||||
|
||||
// Per-account message/byte stats
|
||||
@@ -214,17 +325,42 @@ public sealed class Account : IDisposable
|
||||
private long _inBytes;
|
||||
private long _outBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets total inbound messages observed for this account.
|
||||
/// </summary>
|
||||
public long InMsgs => Interlocked.Read(ref _inMsgs);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total outbound messages observed for this account.
|
||||
/// </summary>
|
||||
public long OutMsgs => Interlocked.Read(ref _outMsgs);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total inbound payload bytes observed for this account.
|
||||
/// </summary>
|
||||
public long InBytes => Interlocked.Read(ref _inBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total outbound payload bytes observed for this account.
|
||||
/// </summary>
|
||||
public long OutBytes => Interlocked.Read(ref _outBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Adds inbound traffic counters for account-level monitoring.
|
||||
/// </summary>
|
||||
/// <param name="msgs">Number of inbound messages to add.</param>
|
||||
/// <param name="bytes">Number of inbound bytes to add.</param>
|
||||
public void IncrementInbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _inMsgs, msgs);
|
||||
Interlocked.Add(ref _inBytes, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds outbound traffic counters for account-level monitoring.
|
||||
/// </summary>
|
||||
/// <param name="msgs">Number of outbound messages to add.</param>
|
||||
/// <param name="bytes">Number of outbound bytes to add.</param>
|
||||
public void IncrementOutbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _outMsgs, msgs);
|
||||
@@ -234,6 +370,10 @@ public sealed class Account : IDisposable
|
||||
// Internal (ACCOUNT) client for import/export message routing
|
||||
private InternalClient? _internalClient;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the account-scoped internal client used for import/export routing.
|
||||
/// </summary>
|
||||
/// <param name="clientId">Client ID to use when creating the internal account client.</param>
|
||||
public InternalClient GetOrCreateInternalClient(ulong clientId)
|
||||
{
|
||||
if (_internalClient != null) return _internalClient;
|
||||
@@ -243,9 +383,13 @@ public sealed class Account : IDisposable
|
||||
|
||||
// Service export latency tracking
|
||||
// Go reference: accounts.go serviceLatency / serviceExportLatencyStats.
|
||||
/// <summary>
|
||||
/// Gets the service latency tracker for this account's exported services.
|
||||
/// </summary>
|
||||
public ServiceLatencyTracker LatencyTracker { get; } = new();
|
||||
|
||||
/// <summary>Records a service request latency sample on this account's tracker.</summary>
|
||||
/// <param name="latencyMs">Observed service latency in milliseconds.</param>
|
||||
public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs);
|
||||
|
||||
/// <summary>
|
||||
@@ -265,6 +409,7 @@ public sealed class Account : IDisposable
|
||||
/// Does not apply wildcard matching.
|
||||
/// Go reference: accounts.go getServiceExport (direct map lookup only).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to resolve.</param>
|
||||
public ServiceExportInfo? GetExactServiceExport(string subject)
|
||||
{
|
||||
if (Exports.Services.TryGetValue(subject, out var se))
|
||||
@@ -277,6 +422,7 @@ public sealed class Account : IDisposable
|
||||
/// wildcard matching. Returns null when no export pattern matches.
|
||||
/// Go reference: accounts.go getWildcardServiceExport (line 2849).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to match against export patterns.</param>
|
||||
public ServiceExportInfo? GetWildcardServiceExport(string subject)
|
||||
{
|
||||
// First try exact match
|
||||
@@ -296,6 +442,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns true when any service export (exact or wildcard) matches the given subject.
|
||||
/// Go reference: accounts.go getServiceExport.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to test.</param>
|
||||
public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null;
|
||||
|
||||
private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se)
|
||||
@@ -307,6 +454,13 @@ public sealed class Account : IDisposable
|
||||
return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a service export for cross-account request forwarding.
|
||||
/// </summary>
|
||||
/// <param name="subject">Exported service subject or subject pattern.</param>
|
||||
/// <param name="responseType">Response policy for this service export.</param>
|
||||
/// <param name="approved">Optional set of accounts authorized to import this service.</param>
|
||||
/// <param name="latency">Optional latency tracking configuration for this export.</param>
|
||||
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved, ServiceLatency? latency = null)
|
||||
{
|
||||
var auth = new ExportAuth
|
||||
@@ -322,6 +476,11 @@ public sealed class Account : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a stream export for cross-account stream delivery.
|
||||
/// </summary>
|
||||
/// <param name="subject">Exported stream subject or subject pattern.</param>
|
||||
/// <param name="approved">Optional set of accounts authorized to import this stream.</param>
|
||||
public void AddStreamExport(string subject, IEnumerable<Account>? approved)
|
||||
{
|
||||
var auth = new ExportAuth
|
||||
@@ -335,6 +494,9 @@ public sealed class Account : IDisposable
|
||||
/// Adds a service import with cycle detection.
|
||||
/// Go reference: accounts.go addServiceImport with checkForImportCycle.
|
||||
/// </summary>
|
||||
/// <param name="destination">Exporter account that owns the target service export.</param>
|
||||
/// <param name="from">Importer-visible subject pattern.</param>
|
||||
/// <param name="to">Exporter service subject to route to.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if no export found or import would create a cycle.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown if this account is not authorized.</exception>
|
||||
public ServiceImport AddServiceImport(Account destination, string from, string to)
|
||||
@@ -364,12 +526,19 @@ public sealed class Account : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Removes a service import by its 'from' subject.</summary>
|
||||
/// <param name="from">Importer-visible subject used when the import was created.</param>
|
||||
/// <returns>True if the import was found and removed.</returns>
|
||||
public bool RemoveServiceImport(string from)
|
||||
{
|
||||
return Imports.Services.Remove(from);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stream import so this account can consume another account's exported stream subjects.
|
||||
/// </summary>
|
||||
/// <param name="source">Exporter account that owns the stream export.</param>
|
||||
/// <param name="from">Exporter stream subject to import from.</param>
|
||||
/// <param name="to">Importer-local subject alias for the stream import.</param>
|
||||
public void AddStreamImport(Account source, string from, string to)
|
||||
{
|
||||
if (!source.Exports.Streams.TryGetValue(from, out var export))
|
||||
@@ -389,6 +558,7 @@ public sealed class Account : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Removes a stream import by its 'from' subject.</summary>
|
||||
/// <param name="from">Importer-visible subject used when the stream import was created.</param>
|
||||
/// <returns>True if the import was found and removed.</returns>
|
||||
public bool RemoveStreamImport(string from)
|
||||
{
|
||||
@@ -404,6 +574,7 @@ public sealed class Account : IDisposable
|
||||
/// Uses DFS through the stream import graph starting at proposedSource, checking if any path leads back to this account.
|
||||
/// Go reference: accounts.go streamImportFormsCycle / checkStreamImportsForCycles.
|
||||
/// </summary>
|
||||
/// <param name="proposedSource">Source account being considered for a new stream import.</param>
|
||||
public bool StreamImportFormsCycle(Account proposedSource)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposedSource);
|
||||
@@ -448,11 +619,15 @@ public sealed class Account : IDisposable
|
||||
/// <summary>
|
||||
/// Returns true if this account has at least one stream import from the account with the given name.
|
||||
/// </summary>
|
||||
/// <param name="accountName">Source account name to check for stream-import relationships.</param>
|
||||
public bool HasStreamImportFrom(string accountName) =>
|
||||
Imports.Streams.Exists(si => string.Equals(si.SourceAccount.Name, accountName, StringComparison.Ordinal));
|
||||
|
||||
// Per-subject service response thresholds.
|
||||
// Go reference: server/accounts.go — serviceExport.respThresh, SetServiceExportResponseThreshold, ServiceExportResponseThreshold.
|
||||
/// <summary>
|
||||
/// Gets per-subject response-time thresholds used for service export SLA checks.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, TimeSpan> ServiceResponseThresholds { get; } =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
@@ -460,6 +635,8 @@ public sealed class Account : IDisposable
|
||||
/// Sets the maximum time a service export responder may take to reply.
|
||||
/// Go reference: accounts.go SetServiceExportResponseThreshold (~line 2522).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject whose threshold is being set.</param>
|
||||
/// <param name="threshold">Maximum allowed response time before a request is considered overdue.</param>
|
||||
public void SetServiceResponseThreshold(string subject, TimeSpan threshold) =>
|
||||
ServiceResponseThresholds[subject] = threshold;
|
||||
|
||||
@@ -467,6 +644,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns the threshold for <paramref name="subject"/>, or <see langword="null"/> if none is set.
|
||||
/// Go reference: accounts.go ServiceExportResponseThreshold (~line 2510).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to query for an explicit threshold.</param>
|
||||
public TimeSpan? GetServiceResponseThreshold(string subject) =>
|
||||
ServiceResponseThresholds.TryGetValue(subject, out var t) ? t : null;
|
||||
|
||||
@@ -475,6 +653,8 @@ public sealed class Account : IDisposable
|
||||
/// for <paramref name="subject"/>. When no threshold is set the response is never considered overdue.
|
||||
/// Go reference: accounts.go — respThresh check inside response-timer logic.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to evaluate.</param>
|
||||
/// <param name="elapsed">Observed response latency for the service request.</param>
|
||||
public bool IsServiceResponseOverdue(string subject, TimeSpan elapsed)
|
||||
{
|
||||
if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold))
|
||||
@@ -486,6 +666,8 @@ public sealed class Account : IDisposable
|
||||
/// Combines threshold lookup and overdue check into a single result.
|
||||
/// Go reference: accounts.go — ServiceExportResponseThreshold + response-timer logic.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to evaluate.</param>
|
||||
/// <param name="elapsed">Observed response latency for the service request.</param>
|
||||
public ServiceResponseThresholdResult CheckServiceResponse(string subject, TimeSpan elapsed)
|
||||
{
|
||||
if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold))
|
||||
@@ -552,6 +734,7 @@ public sealed class Account : IDisposable
|
||||
/// Sets the UTC expiration time for this account.
|
||||
/// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment.
|
||||
/// </summary>
|
||||
/// <param name="expiresAtUtc">UTC timestamp when the account should expire.</param>
|
||||
public void SetExpiration(DateTime expiresAtUtc) =>
|
||||
Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks);
|
||||
|
||||
@@ -562,6 +745,7 @@ public sealed class Account : IDisposable
|
||||
/// Convenience method: sets the expiration to <c>DateTime.UtcNow + <paramref name="ttl"/></c>.
|
||||
/// Go reference: accounts.go — SetExpirationTimer with duration argument.
|
||||
/// </summary>
|
||||
/// <param name="ttl">Duration from now until account expiration.</param>
|
||||
public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl);
|
||||
|
||||
/// <summary>
|
||||
@@ -589,6 +773,8 @@ public sealed class Account : IDisposable
|
||||
/// Registers a JWT activation claim for the given subject.
|
||||
/// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service or stream subject associated with the activation token.</param>
|
||||
/// <param name="claim">Activation claim metadata including issued/expiry timestamps.</param>
|
||||
public void RegisterActivation(string subject, ActivationClaim claim) =>
|
||||
_activations[subject] = claim;
|
||||
|
||||
@@ -597,6 +783,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns a result indicating whether the claim was found and whether it is expired.
|
||||
/// Go reference: accounts.go — checkActivation (~line 2943): act.Expires <= tn ⇒ expired.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service or stream subject whose activation should be checked.</param>
|
||||
public ActivationCheckResult CheckActivationExpiry(string subject)
|
||||
{
|
||||
if (!_activations.TryGetValue(subject, out var claim))
|
||||
@@ -612,6 +799,7 @@ public sealed class Account : IDisposable
|
||||
/// and has passed its expiry time.
|
||||
/// Go reference: accounts.go — act.Expires <= tn check inside checkActivation.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service or stream subject whose activation should be checked.</param>
|
||||
public bool IsActivationExpired(string subject) =>
|
||||
_activations.TryGetValue(subject, out var claim) && claim.IsExpired;
|
||||
|
||||
@@ -683,6 +871,7 @@ public sealed class Account : IDisposable
|
||||
/// incremented so that per-client permission caches are invalidated.
|
||||
/// Go reference: server/accounts.go UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287).
|
||||
/// </summary>
|
||||
/// <param name="newClaims">Fresh account claim snapshot to apply.</param>
|
||||
public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newClaims);
|
||||
@@ -751,6 +940,9 @@ public sealed class Account : IDisposable
|
||||
/// Records which origin account and original reply subject to route the response back to.
|
||||
/// Go reference: accounts.go addRespMapEntry.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Rewritten reply subject used while routing through service imports.</param>
|
||||
/// <param name="originAccount">Original requester account name for return routing.</param>
|
||||
/// <param name="originalReply">Original reply subject to restore before delivery.</param>
|
||||
public void AddReverseRespMapEntry(string replySubject, string originAccount, string originalReply) =>
|
||||
_reverseResponseMap[replySubject] = new ReverseResponseMapEntry(
|
||||
replySubject, originAccount, originalReply, DateTime.UtcNow);
|
||||
@@ -760,6 +952,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns <see langword="null"/> when no mapping exists.
|
||||
/// Go reference: accounts.go checkForReverseEntries.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Rewritten reply subject to resolve back to origin details.</param>
|
||||
public ReverseResponseMapEntry? CheckForReverseEntries(string replySubject) =>
|
||||
_reverseResponseMap.TryGetValue(replySubject, out var entry) ? entry : null;
|
||||
|
||||
@@ -767,6 +960,7 @@ public sealed class Account : IDisposable
|
||||
/// Removes the reverse response mapping for <paramref name="replySubject"/>.
|
||||
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Rewritten reply subject whose reverse mapping should be removed.</param>
|
||||
public bool RemoveReverseRespMapEntry(string replySubject) =>
|
||||
_reverseResponseMap.TryRemove(replySubject, out _);
|
||||
|
||||
@@ -785,6 +979,7 @@ public sealed class Account : IDisposable
|
||||
/// from receiving them.
|
||||
/// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
/// </summary>
|
||||
/// <param name="importSubject">Service import subject to test for local shadowing.</param>
|
||||
public bool ServiceImportShadowed(string importSubject)
|
||||
{
|
||||
var matchResult = SubList.Match(importSubject);
|
||||
@@ -795,12 +990,14 @@ public sealed class Account : IDisposable
|
||||
/// Returns true if this account has at least one matching subscription for the given subject.
|
||||
/// Go reference: accounts.go SubscriptionInterest.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to test for local subscription interest.</param>
|
||||
public bool SubscriptionInterest(string subject) => Interest(subject) > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of matching subscriptions (plain + queue) for the given subject.
|
||||
/// Go reference: accounts.go Interest.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to count matching local subscriptions for.</param>
|
||||
public int Interest(string subject)
|
||||
{
|
||||
var (plainCount, queueCount) = SubList.NumInterest(subject);
|
||||
@@ -818,6 +1015,7 @@ public sealed class Account : IDisposable
|
||||
/// When <paramref name="filter"/> is empty, counts all mappings.
|
||||
/// Go reference: accounts.go NumPendingResponses.
|
||||
/// </summary>
|
||||
/// <param name="filter">Optional service subject filter; empty counts all response mappings.</param>
|
||||
public int NumPendingResponses(string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
@@ -847,6 +1045,8 @@ public sealed class Account : IDisposable
|
||||
/// Removes a response service import mapping.
|
||||
/// Go reference: accounts.go removeRespServiceImport.
|
||||
/// </summary>
|
||||
/// <param name="serviceImport">Response service import instance to remove.</param>
|
||||
/// <param name="reason">Reason code for observability/metrics of the removal.</param>
|
||||
public void RemoveRespServiceImport(ServiceImport? serviceImport, ResponseServiceImportRemovalReason reason = ResponseServiceImportRemovalReason.Ok)
|
||||
{
|
||||
if (serviceImport == null)
|
||||
@@ -924,6 +1124,7 @@ public sealed class Account : IDisposable
|
||||
/// including the list of local subscription subjects that shadow it.
|
||||
/// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
/// </summary>
|
||||
/// <param name="importSubject">Service import subject to inspect for shadowing details.</param>
|
||||
public ShadowCheckResult CheckServiceImportShadowing(string importSubject)
|
||||
{
|
||||
var matchResult = SubList.Match(importSubject);
|
||||
@@ -940,6 +1141,9 @@ public sealed class Account : IDisposable
|
||||
return new ShadowCheckResult(isShadowed, importSubject, shadowingSubs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes account-owned resources, including the subscription index.
|
||||
/// </summary>
|
||||
public void Dispose() => SubList.Dispose();
|
||||
}
|
||||
|
||||
@@ -1006,9 +1210,24 @@ public sealed record RevocationInfo(
|
||||
/// </summary>
|
||||
public sealed class ActivationClaim
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the activated subject path this claim authorizes.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the activation was issued.
|
||||
/// </summary>
|
||||
public required DateTime IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the activation expires.
|
||||
/// </summary>
|
||||
public required DateTime ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issuer key associated with this activation claim.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,6 +38,8 @@ public static class ConfigReloader
|
||||
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change
|
||||
/// is tagged with the appropriate category flags.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options before reload.</param>
|
||||
/// <param name="newOpts">Newly parsed options from config plus CLI overrides.</param>
|
||||
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var changes = new List<IConfigChange>();
|
||||
@@ -135,6 +137,7 @@ public static class ConfigReloader
|
||||
/// Validates a list of config changes and returns error messages for any
|
||||
/// non-reloadable changes (properties that require a server restart).
|
||||
/// </summary>
|
||||
/// <param name="changes">Detected config differences to validate for reload safety.</param>
|
||||
public static List<string> Validate(List<IConfigChange> changes)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
@@ -154,6 +157,9 @@ public static class ConfigReloader
|
||||
/// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
|
||||
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
|
||||
/// </summary>
|
||||
/// <param name="fromConfig">Options parsed from config file to mutate with CLI overrides.</param>
|
||||
/// <param name="cliValues">CLI snapshot values captured at process startup.</param>
|
||||
/// <param name="cliFlags">Set of option names that were explicitly supplied via CLI.</param>
|
||||
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
|
||||
{
|
||||
foreach (var flag in cliFlags)
|
||||
@@ -337,6 +343,9 @@ public static class ConfigReloader
|
||||
/// flags indicating which subsystems need to be notified.
|
||||
/// Reference: Go server/reload.go — applyOptions.
|
||||
/// </summary>
|
||||
/// <param name="changes">Validated config changes to apply.</param>
|
||||
/// <param name="currentOpts">Current in-memory options instance.</param>
|
||||
/// <param name="newOpts">New options values produced by config parse and CLI merge.</param>
|
||||
public static ConfigApplyResult ApplyDiff(
|
||||
List<IConfigChange> changes,
|
||||
NatsOptions currentOpts,
|
||||
@@ -366,6 +375,12 @@ public static class ConfigReloader
|
||||
/// the SIGHUP handler) is responsible for applying the result to the running server.
|
||||
/// Reference: Go server/reload.go — Reload.
|
||||
/// </summary>
|
||||
/// <param name="configFile">Config file path to parse.</param>
|
||||
/// <param name="currentOpts">Current in-memory options to compare against.</param>
|
||||
/// <param name="currentDigest">Current file digest used to skip unchanged reloads.</param>
|
||||
/// <param name="cliSnapshot">Optional CLI snapshot whose overrides must win over config values.</param>
|
||||
/// <param name="cliFlags">CLI option names explicitly set by the operator.</param>
|
||||
/// <param name="ct">Cancellation token for the reload operation.</param>
|
||||
public static async Task<ConfigReloadResult> ReloadAsync(
|
||||
string configFile,
|
||||
NatsOptions currentOpts,
|
||||
@@ -403,6 +418,8 @@ public static class ConfigReloader
|
||||
/// a reload result indicating whether the change is valid.
|
||||
/// Go reference: server/reload.go — Reload with in-memory options comparison.
|
||||
/// </summary>
|
||||
/// <param name="original">Original options baseline.</param>
|
||||
/// <param name="updated">Updated options candidate.</param>
|
||||
public static Task<ReloadFromOptionsResult> ReloadFromOptionsAsync(NatsOptions original, NatsOptions updated)
|
||||
{
|
||||
var changes = Diff(original, updated);
|
||||
@@ -428,6 +445,8 @@ public static class ConfigReloader
|
||||
/// Callers use this to reconcile route/gateway/leaf connections after a hot reload.
|
||||
/// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static ClusterConfigChangeResult ApplyClusterConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new ClusterConfigChangeResult();
|
||||
@@ -471,6 +490,8 @@ public static class ConfigReloader
|
||||
/// Debug → "Debug", otherwise "Information" — matching Go's precedence.
|
||||
/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static LoggingChangeResult ApplyLoggingChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new LoggingChangeResult();
|
||||
@@ -598,6 +619,8 @@ public static class ConfigReloader
|
||||
/// re-evaluation of existing connections after a config reload.
|
||||
/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static AuthChangeResult PropagateAuthChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new AuthChangeResult();
|
||||
@@ -636,6 +659,8 @@ public static class ConfigReloader
|
||||
/// If changed, validates the new cert is loadable.
|
||||
/// Go reference: server/reload.go — tlsConfigReload.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static TlsReloadResult ReloadTlsCertificates(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new TlsReloadResult();
|
||||
@@ -672,6 +697,8 @@ public static class ConfigReloader
|
||||
/// existing connections keep their original certificate.
|
||||
/// Reference: golang/nats-server/server/reload.go — tlsOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="options">Current options containing certificate/key paths.</param>
|
||||
/// <param name="certProvider">Certificate provider to update in place.</param>
|
||||
public static bool ReloadTlsCertificate(
|
||||
NatsOptions options,
|
||||
TlsCertificateProvider? certProvider)
|
||||
@@ -696,6 +723,8 @@ public static class ConfigReloader
|
||||
/// hot reload without requiring a server restart.
|
||||
/// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static JetStreamConfigChangeResult ApplyJetStreamConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new JetStreamConfigChangeResult();
|
||||
@@ -753,12 +782,34 @@ public readonly record struct ConfigApplyResult(
|
||||
/// </summary>
|
||||
public sealed class ConfigReloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether reload was skipped because the config digest did not change.
|
||||
/// </summary>
|
||||
public bool Unchanged { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets newly parsed options when a reload candidate was produced.
|
||||
/// </summary>
|
||||
public NatsOptions? NewOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest for the parsed config file.
|
||||
/// </summary>
|
||||
public string? NewDigest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the detected config changes for this reload attempt.
|
||||
/// </summary>
|
||||
public List<IConfigChange>? Changes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets validation errors detected while evaluating the reload.
|
||||
/// </summary>
|
||||
public List<string>? Errors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a config reload result payload.
|
||||
/// </summary>
|
||||
public ConfigReloadResult(
|
||||
bool Unchanged,
|
||||
NatsOptions? NewOptions = null,
|
||||
@@ -773,6 +824,9 @@ public sealed class ConfigReloadResult
|
||||
this.Errors = Errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this reload result contains validation errors.
|
||||
/// </summary>
|
||||
public bool HasErrors => Errors is { Count: > 0 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,75 @@
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a gateway listener and outbound gateway connections to other clusters.
|
||||
/// </summary>
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Local gateway name advertised to remote clusters.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Interface or host name used by the gateway listener.
|
||||
/// </summary>
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// TCP port used by the gateway listener.
|
||||
/// </summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote gateway URLs from configuration.
|
||||
/// </summary>
|
||||
public List<string> Remotes { get; set; } = [];
|
||||
|
||||
// Go: opts.go — gateway authorization fields
|
||||
/// <summary>
|
||||
/// Rejects inbound gateway connections from clusters that are not explicitly configured.
|
||||
/// </summary>
|
||||
public bool RejectUnknown { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Username for gateway authentication.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for gateway authentication.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication timeout, in seconds, for gateway handshakes.
|
||||
/// </summary>
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional advertise endpoint sent to remote clusters instead of bind host and port.
|
||||
/// </summary>
|
||||
public string? Advertise { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of outbound connection retries before giving up.
|
||||
/// </summary>
|
||||
public int ConnectRetries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables backoff between outbound gateway reconnect attempts.
|
||||
/// </summary>
|
||||
public bool ConnectBackoff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Write deadline applied to outbound gateway socket writes.
|
||||
/// </summary>
|
||||
public TimeSpan WriteDeadline { get; set; }
|
||||
|
||||
// Go: opts.go — gateways remotes list (RemoteGatewayOpts)
|
||||
/// <summary>
|
||||
/// Expanded remote gateway definitions with runtime metadata.
|
||||
/// </summary>
|
||||
public List<RemoteGatewayOptions> RemoteGateways { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -28,12 +80,39 @@ public sealed class RemoteGatewayOptions
|
||||
{
|
||||
private int _connAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Remote gateway cluster name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized remote URLs for this gateway.
|
||||
/// </summary>
|
||||
public List<string> Urls { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that this remote was discovered implicitly rather than configured statically.
|
||||
/// </summary>
|
||||
public bool Implicit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current hash of the URL set used for change detection.
|
||||
/// </summary>
|
||||
public byte[]? Hash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous hash value retained across URL updates.
|
||||
/// </summary>
|
||||
public byte[]? OldHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TLS server name captured from a remote URL host.
|
||||
/// </summary>
|
||||
public string? TlsName { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether URL changes should be surfaced in gateway monitoring endpoints.
|
||||
/// </summary>
|
||||
public bool VarzUpdateUrls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -54,14 +133,30 @@ public sealed class RemoteGatewayOptions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments and returns the number of outbound connection attempts.
|
||||
/// </summary>
|
||||
public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current outbound connection attempt count.
|
||||
/// </summary>
|
||||
public int GetConnAttempts() => Volatile.Read(ref _connAttempts);
|
||||
|
||||
/// <summary>
|
||||
/// Resets outbound connection attempt tracking.
|
||||
/// </summary>
|
||||
public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this remote gateway entry is implicit.
|
||||
/// </summary>
|
||||
public bool IsImplicit() => Implicit;
|
||||
|
||||
/// <summary>
|
||||
/// Returns normalized remote URLs in randomized order for reconnect balancing.
|
||||
/// </summary>
|
||||
/// <param name="random">Optional random source used for URL shuffle order.</param>
|
||||
public List<Uri> GetUrls(Random? random = null)
|
||||
{
|
||||
var urls = new List<Uri>();
|
||||
@@ -81,6 +176,9 @@ public sealed class RemoteGatewayOptions
|
||||
return urls;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns normalized URL strings for diagnostics and monitor payloads.
|
||||
/// </summary>
|
||||
public List<string> GetUrlsAsStrings()
|
||||
{
|
||||
var result = new List<string>();
|
||||
@@ -89,6 +187,11 @@ public sealed class RemoteGatewayOptions
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the URL list with a deduplicated merge of configured and discovered remotes.
|
||||
/// </summary>
|
||||
/// <param name="configuredUrls">Static URLs from server configuration.</param>
|
||||
/// <param name="discoveredUrls">Dynamic URLs discovered from gossip or INFO updates.</param>
|
||||
public void UpdateUrls(IEnumerable<string> configuredUrls, IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
var merged = new List<string>();
|
||||
@@ -97,12 +200,20 @@ public sealed class RemoteGatewayOptions
|
||||
Urls = merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and stores TLS server name from a remote URL.
|
||||
/// </summary>
|
||||
/// <param name="url">Remote URL string.</param>
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryNormalizeRemoteUrl(url, out var uri))
|
||||
TlsName = uri.Host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds discovered URLs to the existing URL list after normalization and deduplication.
|
||||
/// </summary>
|
||||
/// <param name="discoveredUrls">Discovered remote URLs.</param>
|
||||
public void AddUrls(IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
AddUrlsInternal(Urls, discoveredUrls);
|
||||
|
||||
@@ -14,12 +14,39 @@ namespace NATS.Server.Events;
|
||||
/// </summary>
|
||||
public sealed class PublishMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets optional originating internal client context for this publish.
|
||||
/// </summary>
|
||||
public InternalClient? Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the destination subject for the internal publish.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional reply subject.
|
||||
/// </summary>
|
||||
public string? Reply { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional header bytes for HMSG-style delivery.
|
||||
/// </summary>
|
||||
public byte[]? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the payload object to serialize and publish.
|
||||
/// </summary>
|
||||
public object? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this event should be echoed back to the sender context.
|
||||
/// </summary>
|
||||
public bool Echo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this message is the final send-loop item before shutdown.
|
||||
/// </summary>
|
||||
public bool IsLast { get; init; }
|
||||
}
|
||||
|
||||
@@ -28,13 +55,44 @@ public sealed class PublishMessage
|
||||
/// </summary>
|
||||
public sealed class InternalSystemMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched internal subscription.
|
||||
/// </summary>
|
||||
public required Subscription? Sub { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal client delivering the message.
|
||||
/// </summary>
|
||||
public required INatsClient? Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the account context for this internal dispatch.
|
||||
/// </summary>
|
||||
public required Account? Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message subject.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional reply subject.
|
||||
/// </summary>
|
||||
public required string? Reply { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets message header bytes.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets message payload bytes.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets callback invoked by the internal receive loop.
|
||||
/// </summary>
|
||||
public required SystemMessageHandler Callback { get; init; }
|
||||
}
|
||||
|
||||
@@ -113,8 +171,19 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
private readonly ConcurrentDictionary<string, SystemMessageHandler> _callbacks = new();
|
||||
private long _authErrorEventCount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the system account used for advisory routing.
|
||||
/// </summary>
|
||||
public Account SystemAccount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal system client bound to system subscriptions.
|
||||
/// </summary>
|
||||
public InternalClient SystemClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hashed server identifier used in request/reply subjects.
|
||||
/// </summary>
|
||||
public string ServerHash { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -123,6 +192,13 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// </summary>
|
||||
public long AuthErrorEventCount => Interlocked.Read(ref _authErrorEventCount);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the internal event system and initializes send/receive channels.
|
||||
/// </summary>
|
||||
/// <param name="systemAccount">System account used for event publication and matching.</param>
|
||||
/// <param name="systemClient">Internal system client used for callback dispatch.</param>
|
||||
/// <param name="serverName">Server name input for deterministic server hash generation.</param>
|
||||
/// <param name="logger">Logger for send/receive loop diagnostics.</param>
|
||||
public InternalEventSystem(Account systemAccount, InternalClient systemClient, string serverName, ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -145,6 +221,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers.
|
||||
/// </summary>
|
||||
/// <param name="value">Input value to hash.</param>
|
||||
/// <param name="size">Number of hex characters to return.</param>
|
||||
public static string GetHash(string value, int size)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(size, 1);
|
||||
@@ -152,6 +230,10 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
return size >= full.Length ? full : full[..size];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts internal send/receive loops and periodic stats publishing.
|
||||
/// </summary>
|
||||
/// <param name="server">Owning server instance used for stat snapshots and event info.</param>
|
||||
public void Start(NatsServer server)
|
||||
{
|
||||
_server = server;
|
||||
@@ -177,6 +259,7 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Sets up handlers for $SYS.REQ.SERVER.{id}.VARZ, HEALTHZ, SUBSZ, STATSZ, IDZ
|
||||
/// and wildcard $SYS.REQ.SERVER.PING.* subjects.
|
||||
/// </summary>
|
||||
/// <param name="server">Owning server that handles system request subjects.</param>
|
||||
public void InitEventTracking(NatsServer server)
|
||||
{
|
||||
_server = server;
|
||||
@@ -258,6 +341,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Creates a system subscription in the system account's SubList.
|
||||
/// Maps to Go's sysSubscribe in events.go:2796.
|
||||
/// </summary>
|
||||
/// <param name="subject">System subject to subscribe to.</param>
|
||||
/// <param name="callback">Callback invoked for each matching internal message.</param>
|
||||
public Subscription SysSubscribe(string subject, SystemMessageHandler callback)
|
||||
{
|
||||
var sid = Interlocked.Increment(ref _subscriptionId).ToString();
|
||||
@@ -304,6 +389,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Increments <see cref="AuthErrorEventCount"/> each time it is called.
|
||||
/// Go reference: events.go:2631 sendAuthErrorEvent.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server identifier to embed in advisory metadata.</param>
|
||||
/// <param name="detail">Auth error event detail payload.</param>
|
||||
public void SendAuthErrorEvent(string serverId, AuthErrorDetail detail)
|
||||
{
|
||||
var subject = string.Format(EventSubjects.AuthError, serverId);
|
||||
@@ -330,6 +417,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Publishes a client connect advisory to $SYS.ACCOUNT.{account}.CONNECT.
|
||||
/// Go reference: events.go postConnectEvent / sendConnect.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server identifier to embed in advisory metadata.</param>
|
||||
/// <param name="detail">Connect advisory detail payload.</param>
|
||||
public void SendConnectEvent(string serverId, ConnectEventDetail detail)
|
||||
{
|
||||
var accountName = detail.AccountName ?? "$G";
|
||||
@@ -363,6 +452,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Publishes a client disconnect advisory to $SYS.ACCOUNT.{account}.DISCONNECT.
|
||||
/// Go reference: events.go postDisconnectEvent / sendDisconnect.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server identifier to embed in advisory metadata.</param>
|
||||
/// <param name="detail">Disconnect advisory detail payload.</param>
|
||||
public void SendDisconnectEvent(string serverId, DisconnectEventDetail detail)
|
||||
{
|
||||
var accountName = detail.AccountName ?? "$G";
|
||||
@@ -396,6 +487,7 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Enqueue an internal message for publishing through the send loop.
|
||||
/// </summary>
|
||||
/// <param name="message">Internal publish message to queue.</param>
|
||||
public void Enqueue(PublishMessage message)
|
||||
{
|
||||
_sendQueue.Writer.TryWrite(message);
|
||||
@@ -495,6 +587,9 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops event loops, completes channels, and disposes cancellation resources.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
@@ -49,6 +49,7 @@ public class SequenceSet
|
||||
public bool IsEmpty => Root == null;
|
||||
|
||||
/// <summary>Insert will insert the sequence into the set. The tree will be balanced inline.</summary>
|
||||
/// <param name="seq">Sequence value to insert.</param>
|
||||
public void Insert(ulong seq)
|
||||
{
|
||||
Root = Node.Insert(Root, seq, ref _changed, ref _nodes);
|
||||
@@ -60,6 +61,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the sequence is a member of this set.</summary>
|
||||
/// <param name="seq">Sequence value to check.</param>
|
||||
public bool Exists(ulong seq)
|
||||
{
|
||||
var n = Root;
|
||||
@@ -86,6 +88,7 @@ public class SequenceSet
|
||||
/// Sets the initial minimum sequence when known. More effectively utilizes space.
|
||||
/// The set must be empty.
|
||||
/// </summary>
|
||||
/// <param name="min">Initial minimum sequence bucket base.</param>
|
||||
public void SetInitialMin(ulong min)
|
||||
{
|
||||
if (!IsEmpty)
|
||||
@@ -100,6 +103,7 @@ public class SequenceSet
|
||||
/// <summary>
|
||||
/// Removes the sequence from the set. Returns true if the sequence was present.
|
||||
/// </summary>
|
||||
/// <param name="seq">Sequence value to remove.</param>
|
||||
public bool Delete(ulong seq)
|
||||
{
|
||||
if (Root == null)
|
||||
@@ -135,6 +139,7 @@ public class SequenceSet
|
||||
/// Invokes the callback for each item in ascending order.
|
||||
/// If the callback returns false, iteration terminates.
|
||||
/// </summary>
|
||||
/// <param name="callback">Callback invoked for each sequence in ascending order.</param>
|
||||
public void Range(Func<ulong, bool> callback) => Node.Iter(Root, callback);
|
||||
|
||||
/// <summary>Returns the left and right heights of the tree root.</summary>
|
||||
@@ -200,6 +205,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Unions this set with one or more other sets by inserting all their elements.</summary>
|
||||
/// <param name="others">Other sets whose items should be merged into this set.</param>
|
||||
public void Union(params SequenceSet[] others)
|
||||
{
|
||||
foreach (var other in others)
|
||||
@@ -225,6 +231,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Returns a union of all provided sets.</summary>
|
||||
/// <param name="sets">Sets to merge.</param>
|
||||
public static SequenceSet CreateUnion(params SequenceSet[] sets)
|
||||
{
|
||||
if (sets.Length == 0)
|
||||
@@ -263,6 +270,7 @@ public class SequenceSet
|
||||
/// Encodes the set into a caller-provided buffer.
|
||||
/// Returns the number of bytes written.
|
||||
/// </summary>
|
||||
/// <param name="destination">Destination buffer for encoded bytes.</param>
|
||||
public int Encode(byte[] destination)
|
||||
{
|
||||
var encLen = EncodeLength();
|
||||
@@ -294,6 +302,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Decodes a SequenceSet from a binary buffer. Returns the set and number of bytes read.</summary>
|
||||
/// <param name="buf">Encoded sequence-set bytes.</param>
|
||||
public static (SequenceSet Set, int BytesRead) Decode(ReadOnlySpan<byte> buf)
|
||||
{
|
||||
if (buf.Length < MinLen || buf[0] != Magic)
|
||||
@@ -457,6 +466,8 @@ public class SequenceSet
|
||||
public int Height;
|
||||
|
||||
/// <summary>Sets the bit for the given sequence. Reports whether it was newly inserted.</summary>
|
||||
/// <param name="seq">Sequence value whose bit should be set.</param>
|
||||
/// <param name="inserted">Set to true when the bit transitions from 0 to 1.</param>
|
||||
public void SetBit(ulong seq, ref bool inserted)
|
||||
{
|
||||
seq -= Base;
|
||||
@@ -470,6 +481,8 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Clears the bit for the given sequence. Returns true if this node is now empty.</summary>
|
||||
/// <param name="seq">Sequence value whose bit should be cleared.</param>
|
||||
/// <param name="deleted">Set to true when the bit transitions from 1 to 0.</param>
|
||||
public bool ClearBit(ulong seq, ref bool deleted)
|
||||
{
|
||||
seq -= Base;
|
||||
@@ -493,6 +506,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Checks if the bit for the given sequence is set.</summary>
|
||||
/// <param name="seq">Sequence value to test.</param>
|
||||
public bool ExistsBit(ulong seq)
|
||||
{
|
||||
seq -= Base;
|
||||
@@ -530,6 +544,10 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Inserts a sequence into the subtree rooted at this node, rebalancing as needed.</summary>
|
||||
/// <param name="n">Root node for the current subtree.</param>
|
||||
/// <param name="seq">Sequence value to insert.</param>
|
||||
/// <param name="inserted">Set to true when a new bit is inserted.</param>
|
||||
/// <param name="nodes">Node count updated when new AVL nodes are created.</param>
|
||||
public static Node Insert(Node? n, ulong seq, ref bool inserted, ref int nodes)
|
||||
{
|
||||
if (n == null)
|
||||
@@ -580,6 +598,10 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Deletes a sequence from the subtree rooted at this node, rebalancing as needed.</summary>
|
||||
/// <param name="n">Root node for the current subtree.</param>
|
||||
/// <param name="seq">Sequence value to remove.</param>
|
||||
/// <param name="deleted">Set to true when a bit is removed.</param>
|
||||
/// <param name="nodes">Node count updated when AVL nodes are removed.</param>
|
||||
public static Node? Delete(Node? n, ulong seq, ref bool deleted, ref int nodes)
|
||||
{
|
||||
if (n == null)
|
||||
@@ -721,6 +743,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Returns the balance factor (left height - right height).</summary>
|
||||
/// <param name="n">Node to evaluate.</param>
|
||||
internal static int BalanceFactor(Node? n)
|
||||
{
|
||||
if (n == null)
|
||||
@@ -734,6 +757,7 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Returns the max of left and right child heights.</summary>
|
||||
/// <param name="n">Node to evaluate.</param>
|
||||
internal static int MaxHeight(Node? n)
|
||||
{
|
||||
if (n == null)
|
||||
@@ -747,6 +771,8 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Iterates nodes in pre-order (root, left, right) for encoding.</summary>
|
||||
/// <param name="n">Subtree root.</param>
|
||||
/// <param name="f">Action invoked for each visited node.</param>
|
||||
internal static void NodeIter(Node? n, Action<Node> f)
|
||||
{
|
||||
if (n == null)
|
||||
@@ -760,6 +786,8 @@ public class SequenceSet
|
||||
}
|
||||
|
||||
/// <summary>Iterates items in ascending order. Returns false if iteration was terminated early.</summary>
|
||||
/// <param name="n">Subtree root.</param>
|
||||
/// <param name="f">Callback invoked per sequence; return false to stop iteration.</param>
|
||||
internal static bool Iter(Node? n, Func<ulong, bool> f)
|
||||
{
|
||||
if (n == null)
|
||||
|
||||
@@ -6,24 +6,87 @@ namespace NATS.Server.Internal.SubjectTree;
|
||||
/// </summary>
|
||||
internal interface INode
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this node is a terminal subject node that directly stores a subscription value.
|
||||
/// </summary>
|
||||
bool IsLeaf { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets structural metadata for branch nodes, including compressed path prefix and child count.
|
||||
/// </summary>
|
||||
NodeMeta? Base { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the compressed path fragment represented by this node.
|
||||
/// </summary>
|
||||
/// <param name="pre">Subject bytes shared by all descendants below this node.</param>
|
||||
void SetPrefix(ReadOnlySpan<byte> pre);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child edge for the next subject byte in the adaptive radix tree.
|
||||
/// </summary>
|
||||
/// <param name="c">Subject byte used to route lookups to the child node.</param>
|
||||
/// <param name="n">Child node that owns the remaining subject suffix for this edge.</param>
|
||||
void AddChild(byte c, INode n);
|
||||
/// <summary>
|
||||
/// Returns the child node for the given key byte, or null if not found.
|
||||
/// The returned wrapper allows in-place replacement of the child reference.
|
||||
/// </summary>
|
||||
/// <param name="c">Subject byte to look up in the node's child index.</param>
|
||||
ChildRef? FindChild(byte c);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the child edge for the provided subject byte.
|
||||
/// </summary>
|
||||
/// <param name="c">Subject byte whose child mapping should be removed.</param>
|
||||
void DeleteChild(byte c);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this node has reached its capacity and must grow to the next node shape.
|
||||
/// </summary>
|
||||
bool IsFull { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Expands this node to a larger branching factor to accept more distinct subject bytes.
|
||||
/// </summary>
|
||||
INode Grow();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to shrink this node to a smaller branching representation when sparse.
|
||||
/// </summary>
|
||||
INode? Shrink();
|
||||
|
||||
/// <summary>
|
||||
/// Matches a subject split into tokens against this node's compressed path fragment.
|
||||
/// </summary>
|
||||
/// <param name="parts">Remaining subject tokens to match from this node downward.</param>
|
||||
/// <returns>The remaining tokens after consuming this node, and whether the fragment matched.</returns>
|
||||
(ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a short node kind name used by diagnostics and debugging tools.
|
||||
/// </summary>
|
||||
string Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Iterates child nodes until the callback returns <see langword="false" />.
|
||||
/// </summary>
|
||||
/// <param name="f">Callback invoked for each child node in this branch.</param>
|
||||
void Iter(Func<INode, bool> f);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current child nodes for traversal or inspection.
|
||||
/// </summary>
|
||||
INode?[] Children();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active child edges in this node.
|
||||
/// </summary>
|
||||
ushort NumChildren { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compressed path bytes represented by this node.
|
||||
/// </summary>
|
||||
byte[] Path();
|
||||
}
|
||||
|
||||
@@ -33,6 +96,9 @@ internal interface INode
|
||||
/// </summary>
|
||||
internal sealed class ChildRef(Func<INode?> getter, Action<INode?> setter)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or replaces the child node reference stored at a specific branch slot.
|
||||
/// </summary>
|
||||
public INode? Node
|
||||
{
|
||||
get => getter();
|
||||
@@ -45,7 +111,14 @@ internal sealed class ChildRef(Func<INode?> getter, Action<INode?> setter)
|
||||
/// </summary>
|
||||
internal sealed class NodeMeta
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the compressed subject prefix shared by descendants of this branch node.
|
||||
/// </summary>
|
||||
public byte[] Prefix { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of child edges currently populated for this branch node.
|
||||
/// </summary>
|
||||
public ushort Size { get; set; }
|
||||
}
|
||||
|
||||
@@ -60,28 +133,51 @@ internal sealed class Leaf<T> : INode
|
||||
public T Value;
|
||||
public byte[] Suffix;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a terminal subject-tree node that stores a value for an exact suffix match.
|
||||
/// </summary>
|
||||
/// <param name="suffix">Remaining subject bytes that must match to resolve this leaf.</param>
|
||||
/// <param name="value">Subscription payload or state associated with the matched subject.</param>
|
||||
public Leaf(ReadOnlySpan<byte> suffix, T value)
|
||||
{
|
||||
Value = value;
|
||||
Suffix = Parts.CopyBytes(suffix);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLeaf => true;
|
||||
/// <inheritdoc />
|
||||
public NodeMeta? Base => null;
|
||||
/// <inheritdoc />
|
||||
public bool IsFull => true;
|
||||
/// <inheritdoc />
|
||||
public ushort NumChildren => 0;
|
||||
/// <inheritdoc />
|
||||
public string Kind => "LEAF";
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided subject bytes exactly match this leaf suffix.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject bytes remaining after traversing parent branch prefixes.</param>
|
||||
/// <returns><see langword="true" /> when the subject resolves to this exact leaf.</returns>
|
||||
public bool Match(ReadOnlySpan<byte> subject) => subject.SequenceEqual(Suffix);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the stored suffix when leaf content is split or merged during tree updates.
|
||||
/// </summary>
|
||||
/// <param name="suffix">New exact-match suffix bytes for this leaf.</param>
|
||||
public void SetSuffix(ReadOnlySpan<byte> suffix) => Suffix = Parts.CopyBytes(suffix);
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] Path() => Suffix;
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode?[] Children() => [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Iter(Func<INode, bool> f) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
|
||||
=> Parts.MatchPartsAgainstFragment(parts, Suffix);
|
||||
|
||||
@@ -108,23 +204,35 @@ internal sealed class Node4 : INode
|
||||
private readonly byte[] _key = new byte[4];
|
||||
internal readonly NodeMeta Meta = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a small branch node for up to four subject-byte fan-out edges.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Compressed subject prefix represented by this branch.</param>
|
||||
public Node4(ReadOnlySpan<byte> prefix)
|
||||
{
|
||||
SetPrefix(prefix);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLeaf => false;
|
||||
/// <inheritdoc />
|
||||
public NodeMeta? Base => Meta;
|
||||
/// <inheritdoc />
|
||||
public ushort NumChildren => Meta.Size;
|
||||
/// <inheritdoc />
|
||||
public bool IsFull => Meta.Size >= 4;
|
||||
/// <inheritdoc />
|
||||
public string Kind => "NODE4";
|
||||
/// <inheritdoc />
|
||||
public byte[] Path() => Meta.Prefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrefix(ReadOnlySpan<byte> pre)
|
||||
{
|
||||
Meta.Prefix = pre.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddChild(byte c, INode n)
|
||||
{
|
||||
if (Meta.Size >= 4) throw new InvalidOperationException("node4 full!");
|
||||
@@ -133,6 +241,7 @@ internal sealed class Node4 : INode
|
||||
Meta.Size++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChildRef? FindChild(byte c)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -146,6 +255,7 @@ internal sealed class Node4 : INode
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteChild(byte c)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -171,6 +281,7 @@ internal sealed class Node4 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode Grow()
|
||||
{
|
||||
var nn = new Node10(Meta.Prefix);
|
||||
@@ -181,12 +292,14 @@ internal sealed class Node4 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode? Shrink()
|
||||
{
|
||||
if (Meta.Size == 1) return _child[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Iter(Func<INode, bool> f)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -195,6 +308,7 @@ internal sealed class Node4 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode?[] Children()
|
||||
{
|
||||
var result = new INode?[Meta.Size];
|
||||
@@ -202,6 +316,7 @@ internal sealed class Node4 : INode
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
|
||||
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
|
||||
}
|
||||
@@ -220,23 +335,35 @@ internal sealed class Node10 : INode
|
||||
private readonly byte[] _key = new byte[10];
|
||||
internal readonly NodeMeta Meta = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a branch node tuned for numeric token fan-out, common in ordered stream subjects.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Compressed subject prefix represented by this branch.</param>
|
||||
public Node10(ReadOnlySpan<byte> prefix)
|
||||
{
|
||||
SetPrefix(prefix);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLeaf => false;
|
||||
/// <inheritdoc />
|
||||
public NodeMeta? Base => Meta;
|
||||
/// <inheritdoc />
|
||||
public ushort NumChildren => Meta.Size;
|
||||
/// <inheritdoc />
|
||||
public bool IsFull => Meta.Size >= 10;
|
||||
/// <inheritdoc />
|
||||
public string Kind => "NODE10";
|
||||
/// <inheritdoc />
|
||||
public byte[] Path() => Meta.Prefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrefix(ReadOnlySpan<byte> pre)
|
||||
{
|
||||
Meta.Prefix = pre.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddChild(byte c, INode n)
|
||||
{
|
||||
if (Meta.Size >= 10) throw new InvalidOperationException("node10 full!");
|
||||
@@ -245,6 +372,7 @@ internal sealed class Node10 : INode
|
||||
Meta.Size++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChildRef? FindChild(byte c)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -258,6 +386,7 @@ internal sealed class Node10 : INode
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteChild(byte c)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -283,6 +412,7 @@ internal sealed class Node10 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode Grow()
|
||||
{
|
||||
var nn = new Node16(Meta.Prefix);
|
||||
@@ -293,6 +423,7 @@ internal sealed class Node10 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode? Shrink()
|
||||
{
|
||||
if (Meta.Size > 4) return null;
|
||||
@@ -304,6 +435,7 @@ internal sealed class Node10 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Iter(Func<INode, bool> f)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -312,6 +444,7 @@ internal sealed class Node10 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode?[] Children()
|
||||
{
|
||||
var result = new INode?[Meta.Size];
|
||||
@@ -319,6 +452,7 @@ internal sealed class Node10 : INode
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
|
||||
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
|
||||
}
|
||||
@@ -337,23 +471,35 @@ internal sealed class Node16 : INode
|
||||
private readonly byte[] _key = new byte[16];
|
||||
internal readonly NodeMeta Meta = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a medium branch node for moderate subject fan-out without index indirection.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Compressed subject prefix represented by this branch.</param>
|
||||
public Node16(ReadOnlySpan<byte> prefix)
|
||||
{
|
||||
SetPrefix(prefix);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLeaf => false;
|
||||
/// <inheritdoc />
|
||||
public NodeMeta? Base => Meta;
|
||||
/// <inheritdoc />
|
||||
public ushort NumChildren => Meta.Size;
|
||||
/// <inheritdoc />
|
||||
public bool IsFull => Meta.Size >= 16;
|
||||
/// <inheritdoc />
|
||||
public string Kind => "NODE16";
|
||||
/// <inheritdoc />
|
||||
public byte[] Path() => Meta.Prefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrefix(ReadOnlySpan<byte> pre)
|
||||
{
|
||||
Meta.Prefix = pre.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddChild(byte c, INode n)
|
||||
{
|
||||
if (Meta.Size >= 16) throw new InvalidOperationException("node16 full!");
|
||||
@@ -362,6 +508,7 @@ internal sealed class Node16 : INode
|
||||
Meta.Size++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChildRef? FindChild(byte c)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -375,6 +522,7 @@ internal sealed class Node16 : INode
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteChild(byte c)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -400,6 +548,7 @@ internal sealed class Node16 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode Grow()
|
||||
{
|
||||
var nn = new Node48(Meta.Prefix);
|
||||
@@ -410,6 +559,7 @@ internal sealed class Node16 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode? Shrink()
|
||||
{
|
||||
if (Meta.Size > 10) return null;
|
||||
@@ -421,6 +571,7 @@ internal sealed class Node16 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Iter(Func<INode, bool> f)
|
||||
{
|
||||
for (int i = 0; i < Meta.Size; i++)
|
||||
@@ -429,6 +580,7 @@ internal sealed class Node16 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode?[] Children()
|
||||
{
|
||||
var result = new INode?[Meta.Size];
|
||||
@@ -436,6 +588,7 @@ internal sealed class Node16 : INode
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
|
||||
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
|
||||
}
|
||||
@@ -454,23 +607,35 @@ internal sealed class Node48 : INode
|
||||
internal readonly byte[] Key = new byte[256]; // 1-indexed: 0 means no entry
|
||||
internal readonly NodeMeta Meta = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a high fan-out branch node that trades memory for faster byte-key lookups.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Compressed subject prefix represented by this branch.</param>
|
||||
public Node48(ReadOnlySpan<byte> prefix)
|
||||
{
|
||||
SetPrefix(prefix);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLeaf => false;
|
||||
/// <inheritdoc />
|
||||
public NodeMeta? Base => Meta;
|
||||
/// <inheritdoc />
|
||||
public ushort NumChildren => Meta.Size;
|
||||
/// <inheritdoc />
|
||||
public bool IsFull => Meta.Size >= 48;
|
||||
/// <inheritdoc />
|
||||
public string Kind => "NODE48";
|
||||
/// <inheritdoc />
|
||||
public byte[] Path() => Meta.Prefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrefix(ReadOnlySpan<byte> pre)
|
||||
{
|
||||
Meta.Prefix = pre.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddChild(byte c, INode n)
|
||||
{
|
||||
if (Meta.Size >= 48) throw new InvalidOperationException("node48 full!");
|
||||
@@ -479,6 +644,7 @@ internal sealed class Node48 : INode
|
||||
Meta.Size++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChildRef? FindChild(byte c)
|
||||
{
|
||||
var i = Key[c];
|
||||
@@ -487,6 +653,7 @@ internal sealed class Node48 : INode
|
||||
return new ChildRef(() => Child[idx], v => Child[idx] = v);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteChild(byte c)
|
||||
{
|
||||
var i = Key[c];
|
||||
@@ -510,6 +677,7 @@ internal sealed class Node48 : INode
|
||||
Meta.Size--;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode Grow()
|
||||
{
|
||||
var nn = new Node256(Meta.Prefix);
|
||||
@@ -524,6 +692,7 @@ internal sealed class Node48 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode? Shrink()
|
||||
{
|
||||
if (Meta.Size > 16) return null;
|
||||
@@ -539,6 +708,7 @@ internal sealed class Node48 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Iter(Func<INode, bool> f)
|
||||
{
|
||||
foreach (var c in Child)
|
||||
@@ -547,6 +717,7 @@ internal sealed class Node48 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode?[] Children()
|
||||
{
|
||||
var result = new INode?[Meta.Size];
|
||||
@@ -554,6 +725,7 @@ internal sealed class Node48 : INode
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
|
||||
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
|
||||
}
|
||||
@@ -571,35 +743,49 @@ internal sealed class Node256 : INode
|
||||
internal readonly INode?[] Child = new INode?[256];
|
||||
internal readonly NodeMeta Meta = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the maximum fan-out branch node with direct byte-to-child indexing.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Compressed subject prefix represented by this branch.</param>
|
||||
public Node256(ReadOnlySpan<byte> prefix)
|
||||
{
|
||||
SetPrefix(prefix);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLeaf => false;
|
||||
/// <inheritdoc />
|
||||
public NodeMeta? Base => Meta;
|
||||
/// <inheritdoc />
|
||||
public ushort NumChildren => Meta.Size;
|
||||
/// <inheritdoc />
|
||||
public bool IsFull => false; // node256 is never full
|
||||
/// <inheritdoc />
|
||||
public string Kind => "NODE256";
|
||||
/// <inheritdoc />
|
||||
public byte[] Path() => Meta.Prefix;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetPrefix(ReadOnlySpan<byte> pre)
|
||||
{
|
||||
Meta.Prefix = pre.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddChild(byte c, INode n)
|
||||
{
|
||||
Child[c] = n;
|
||||
Meta.Size++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChildRef? FindChild(byte c)
|
||||
{
|
||||
if (Child[c] == null) return null;
|
||||
return new ChildRef(() => Child[c], v => Child[c] = v);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteChild(byte c)
|
||||
{
|
||||
if (Child[c] != null)
|
||||
@@ -609,8 +795,10 @@ internal sealed class Node256 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode Grow() => throw new InvalidOperationException("grow can not be called on node256");
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode? Shrink()
|
||||
{
|
||||
if (Meta.Size > 48) return null;
|
||||
@@ -625,6 +813,7 @@ internal sealed class Node256 : INode
|
||||
return nn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Iter(Func<INode, bool> f)
|
||||
{
|
||||
for (int i = 0; i < 256; i++)
|
||||
@@ -636,12 +825,14 @@ internal sealed class Node256 : INode
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public INode?[] Children()
|
||||
{
|
||||
// Return the full 256 array, same as Go
|
||||
return (INode?[])Child.Clone();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
|
||||
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ public class SubjectTree<T>
|
||||
/// Insert a value into the tree. Returns (oldValue, existed).
|
||||
/// If the subject already existed, oldValue is the previous value and existed is true.
|
||||
/// </summary>
|
||||
/// <param name="subject">Literal subject to insert.</param>
|
||||
/// <param name="value">Value stored for the subject.</param>
|
||||
public (T? OldValue, bool Existed) Insert(ReadOnlySpan<byte> subject, T value)
|
||||
{
|
||||
// Make sure we never insert anything with a noPivot byte.
|
||||
@@ -53,6 +55,7 @@ public class SubjectTree<T>
|
||||
/// <summary>
|
||||
/// Find the value for an exact subject match.
|
||||
/// </summary>
|
||||
/// <param name="subject">Literal subject to lookup.</param>
|
||||
public (T? Value, bool Found) Find(ReadOnlySpan<byte> subject)
|
||||
{
|
||||
int si = 0;
|
||||
@@ -98,6 +101,7 @@ public class SubjectTree<T>
|
||||
/// Delete the item for the given subject.
|
||||
/// Returns (deletedValue, wasFound).
|
||||
/// </summary>
|
||||
/// <param name="subject">Literal subject to delete.</param>
|
||||
public (T? Value, bool Found) Delete(ReadOnlySpan<byte> subject)
|
||||
{
|
||||
if (subject.Length == 0)
|
||||
@@ -116,6 +120,8 @@ public class SubjectTree<T>
|
||||
/// <summary>
|
||||
/// Match against a filter subject with wildcards and invoke the callback for each matched value.
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter subject which may include wildcards.</param>
|
||||
/// <param name="callback">Callback invoked for each matched subject/value pair.</param>
|
||||
public void Match(ReadOnlySpan<byte> filter, Action<byte[], T>? callback)
|
||||
{
|
||||
if (Root == null || filter.Length == 0 || callback == null)
|
||||
@@ -136,6 +142,8 @@ public class SubjectTree<T>
|
||||
/// Returning false from the callback stops matching immediately.
|
||||
/// Returns true if matching ran to completion, false if callback stopped it early.
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter subject which may include wildcards.</param>
|
||||
/// <param name="callback">Callback invoked for each match; return false to stop early.</param>
|
||||
public bool MatchUntil(ReadOnlySpan<byte> filter, Func<byte[], T, bool>? callback)
|
||||
{
|
||||
if (Root == null || filter.Length == 0 || callback == null)
|
||||
@@ -150,6 +158,7 @@ public class SubjectTree<T>
|
||||
/// <summary>
|
||||
/// Walk all entries in lexicographic order. The callback can return false to terminate.
|
||||
/// </summary>
|
||||
/// <param name="cb">Callback invoked in lexicographic subject order.</param>
|
||||
public void IterOrdered(Func<byte[], T, bool> cb)
|
||||
{
|
||||
if (Root == null) return;
|
||||
@@ -159,6 +168,7 @@ public class SubjectTree<T>
|
||||
/// <summary>
|
||||
/// Walk all entries in no guaranteed order. The callback can return false to terminate.
|
||||
/// </summary>
|
||||
/// <param name="cb">Callback invoked for each entry.</param>
|
||||
public void IterFast(Func<byte[], T, bool> cb)
|
||||
{
|
||||
if (Root == null) return;
|
||||
@@ -169,6 +179,7 @@ public class SubjectTree<T>
|
||||
/// Dumps a human-readable representation of the tree.
|
||||
/// Go reference: server/stree/dump.go
|
||||
/// </summary>
|
||||
/// <param name="writer">Text writer that receives dump output.</param>
|
||||
public void Dump(TextWriter writer)
|
||||
{
|
||||
Dump(writer, Root, 0);
|
||||
@@ -433,6 +444,10 @@ public class SubjectTree<T>
|
||||
/// Internal recursive match.
|
||||
/// Go reference: server/stree/stree.go:match
|
||||
/// </summary>
|
||||
/// <param name="n">Current node being matched.</param>
|
||||
/// <param name="parts">Remaining tokenized filter parts.</param>
|
||||
/// <param name="pre">Accumulated subject prefix.</param>
|
||||
/// <param name="cb">Match callback.</param>
|
||||
internal bool MatchInternal(INode? n, ReadOnlyMemory<byte>[] parts, byte[] pre, Func<byte[], T, bool> cb)
|
||||
{
|
||||
// Capture if we are sitting on a terminal fwc.
|
||||
@@ -562,6 +577,10 @@ public class SubjectTree<T>
|
||||
/// Internal iter function to walk nodes.
|
||||
/// Go reference: server/stree/stree.go:iter
|
||||
/// </summary>
|
||||
/// <param name="n">Current node being iterated.</param>
|
||||
/// <param name="pre">Accumulated subject prefix.</param>
|
||||
/// <param name="ordered">Whether iteration should be lexicographically ordered.</param>
|
||||
/// <param name="cb">Iteration callback.</param>
|
||||
internal bool IterInternal(INode n, byte[] pre, bool ordered, Func<byte[], T, bool> cb)
|
||||
{
|
||||
if (n.IsLeaf)
|
||||
@@ -634,6 +653,11 @@ public static class SubjectTreeHelper
|
||||
/// Iterates the smaller of the two provided subject trees and looks for matching entries in the other.
|
||||
/// Go reference: server/stree/stree.go:LazyIntersect
|
||||
/// </summary>
|
||||
/// <typeparam name="TL">Value type stored in the left tree.</typeparam>
|
||||
/// <typeparam name="TR">Value type stored in the right tree.</typeparam>
|
||||
/// <param name="tl">Left tree.</param>
|
||||
/// <param name="tr">Right tree.</param>
|
||||
/// <param name="cb">Callback invoked for each shared subject.</param>
|
||||
public static void LazyIntersect<TL, TR>(SubjectTree<TL>? tl, SubjectTree<TR>? tr, Action<byte[], TL, TR> cb)
|
||||
{
|
||||
if (tl == null || tr == null || tl.Root == null || tr.Root == null)
|
||||
@@ -672,6 +696,11 @@ public static class SubjectTreeHelper
|
||||
/// The callback is invoked at most once per matching subject.
|
||||
/// Go reference: server/stree/stree.go IntersectGSL
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Value type stored in the subject tree.</typeparam>
|
||||
/// <typeparam name="SL">Value type stored in the generic subject list.</typeparam>
|
||||
/// <param name="tree">Subject tree to iterate.</param>
|
||||
/// <param name="sublist">Generic subject list used for interest checks.</param>
|
||||
/// <param name="cb">Callback invoked for each subject that has interest.</param>
|
||||
public static void IntersectGSL<T, SL>(
|
||||
SubjectTree<T>? tree,
|
||||
GenericSubjectList<SL>? sublist,
|
||||
|
||||
@@ -32,6 +32,12 @@ public static class StreamApiHandlers
|
||||
private const string SnapshotPrefix = JetStreamApiSubjects.StreamSnapshot;
|
||||
private const string RestorePrefix = JetStreamApiSubjects.StreamRestore;
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream create API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Create request payload.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleCreate(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, CreatePrefix);
|
||||
@@ -48,6 +54,11 @@ public static class StreamApiHandlers
|
||||
return streamManager.CreateOrUpdate(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream info API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleInfo(string subject, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, InfoPrefix);
|
||||
@@ -57,6 +68,12 @@ public static class StreamApiHandlers
|
||||
return streamManager.GetInfo(streamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream update API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Update request payload.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleUpdate(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, UpdatePrefix);
|
||||
@@ -78,6 +95,11 @@ public static class StreamApiHandlers
|
||||
return streamManager.CreateOrUpdate(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream delete API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleDelete(string subject, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, DeletePrefix);
|
||||
@@ -93,6 +115,9 @@ public static class StreamApiHandlers
|
||||
/// Handles stream purge with optional filter, seq, and keep options.
|
||||
/// Go reference: jetstream_api.go:1200-1350.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Purge request payload.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandlePurge(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, PurgePrefix);
|
||||
@@ -107,6 +132,11 @@ public static class StreamApiHandlers
|
||||
return JetStreamApiResponse.PurgeResponse((ulong)purged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream names listing API requests.
|
||||
/// </summary>
|
||||
/// <param name="payload">Pagination request payload.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleNames(ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var offset = ParseOffset(payload);
|
||||
@@ -120,6 +150,11 @@ public static class StreamApiHandlers
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream list API requests and returns stream info pages.
|
||||
/// </summary>
|
||||
/// <param name="payload">Pagination request payload.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleList(ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var offset = ParseOffset(payload);
|
||||
@@ -151,6 +186,12 @@ public static class StreamApiHandlers
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream message-get API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Message-get request payload with sequence.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleMessageGet(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, MessageGetPrefix);
|
||||
@@ -176,6 +217,12 @@ public static class StreamApiHandlers
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles stream message-delete API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Message-delete request payload with sequence.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleMessageDelete(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, MessageDeletePrefix);
|
||||
@@ -191,6 +238,11 @@ public static class StreamApiHandlers
|
||||
: JetStreamApiResponse.NotFound(subject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles synchronous snapshot API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleSnapshot(string subject, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, SnapshotPrefix);
|
||||
@@ -210,6 +262,12 @@ public static class StreamApiHandlers
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles synchronous restore API requests.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Restore request payload containing snapshot data.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
public static JetStreamApiResponse HandleRestore(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var streamName = ExtractTrailingToken(subject, RestorePrefix);
|
||||
@@ -230,6 +288,9 @@ public static class StreamApiHandlers
|
||||
/// and enriches the response with stream name and chunk metadata.
|
||||
/// Go reference: server/jetstream_api.go — jsStreamSnapshotT handler.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
/// <param name="ct">Cancellation token for asynchronous work.</param>
|
||||
public static async Task<JetStreamApiResponse> HandleSnapshotAsync(
|
||||
string subject,
|
||||
StreamManager streamManager,
|
||||
@@ -264,6 +325,10 @@ public static class StreamApiHandlers
|
||||
/// Async restore handler that validates the payload and returns a structured error on failure.
|
||||
/// Go reference: server/jetstream_api.go — jsStreamRestoreT handler.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Serialized restore payload.</param>
|
||||
/// <param name="streamManager">Stream manager that owns local stream state.</param>
|
||||
/// <param name="ct">Cancellation token for asynchronous work.</param>
|
||||
public static async Task<JetStreamApiResponse> HandleRestoreAsync(
|
||||
string subject,
|
||||
byte[] payload,
|
||||
@@ -296,6 +361,10 @@ public static class StreamApiHandlers
|
||||
/// <see cref="JetStreamMetaGroup.ProposeCreateStreamValidatedAsync"/>.
|
||||
/// Go reference: jetstream_cluster.go:7620 jsClusteredStreamRequest.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Serialized stream config payload.</param>
|
||||
/// <param name="metaGroup">JetStream meta-group coordinator.</param>
|
||||
/// <param name="ct">Cancellation token for consensus proposal.</param>
|
||||
public static async Task<JetStreamApiResponse> HandleClusteredCreateAsync(
|
||||
string subject,
|
||||
byte[] payload,
|
||||
@@ -330,6 +399,10 @@ public static class StreamApiHandlers
|
||||
/// Calls <see cref="JetStreamMetaGroup.ProcessUpdateStreamAssignment"/> after validating leadership.
|
||||
/// Go reference: jetstream_cluster.go jsClusteredStreamUpdateRequest.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="payload">Serialized stream config payload.</param>
|
||||
/// <param name="metaGroup">JetStream meta-group coordinator.</param>
|
||||
/// <param name="ct">Cancellation token for consensus proposal.</param>
|
||||
public static async Task<JetStreamApiResponse> HandleClusteredUpdateAsync(
|
||||
string subject,
|
||||
byte[] payload,
|
||||
@@ -371,6 +444,9 @@ public static class StreamApiHandlers
|
||||
/// Calls <see cref="JetStreamMetaGroup.ProposeDeleteStreamValidatedAsync"/> after validating leadership.
|
||||
/// Go reference: jetstream_cluster.go jsClusteredStreamDeleteRequest.
|
||||
/// </summary>
|
||||
/// <param name="subject">API subject containing the target stream name.</param>
|
||||
/// <param name="metaGroup">JetStream meta-group coordinator.</param>
|
||||
/// <param name="ct">Cancellation token for consensus proposal.</param>
|
||||
public static async Task<JetStreamApiResponse> HandleClusteredDeleteAsync(
|
||||
string subject,
|
||||
JetStreamMetaGroup metaGroup,
|
||||
@@ -408,6 +484,10 @@ public static class StreamApiHandlers
|
||||
return token.Length == 0 ? null : token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses stream purge request options from JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="payload">Raw JSON payload.</param>
|
||||
internal static PurgeRequest ParsePurgeRequest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
|
||||
@@ -8,10 +8,29 @@ namespace NATS.Server.JetStream.Cluster;
|
||||
/// </summary>
|
||||
public sealed class RaftGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets raft group name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets peer IDs currently assigned to the group.
|
||||
/// </summary>
|
||||
public List<string> Peers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets storage type used by group replicas.
|
||||
/// </summary>
|
||||
public string StorageType { get; set; } = "file";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets cluster name associated with this raft group.
|
||||
/// </summary>
|
||||
public string Cluster { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets preferred leader peer ID.
|
||||
/// </summary>
|
||||
public string Preferred { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
@@ -26,7 +45,15 @@ public sealed class RaftGroup
|
||||
/// </summary>
|
||||
public bool HasDesiredReplicas => DesiredReplicas > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum acknowledgements required for quorum.
|
||||
/// </summary>
|
||||
public int QuorumSize => (Peers.Count / 2) + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the acknowledgement count satisfies quorum.
|
||||
/// </summary>
|
||||
/// <param name="ackCount">Number of acknowledgements received.</param>
|
||||
public bool HasQuorum(int ackCount) => ackCount >= QuorumSize;
|
||||
|
||||
/// <summary>
|
||||
@@ -45,6 +72,7 @@ public sealed class RaftGroup
|
||||
/// Returns true if the given peerId is a member of this group (case-sensitive).
|
||||
/// Go reference: jetstream_cluster.go isMember helper.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier to check.</param>
|
||||
public bool IsMember(string peerId) => Peers.Contains(peerId, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
@@ -52,6 +80,7 @@ public sealed class RaftGroup
|
||||
/// Throws <see cref="InvalidOperationException"/> if peerId is not a member.
|
||||
/// Go reference: jetstream_cluster.go setPreferred / rg.Preferred assignment.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier to set as preferred leader.</param>
|
||||
public void SetPreferred(string peerId)
|
||||
{
|
||||
if (!IsMember(peerId))
|
||||
@@ -65,6 +94,7 @@ public sealed class RaftGroup
|
||||
/// <see cref="Preferred"/> is cleared. Returns true if the peer was found and removed.
|
||||
/// Go reference: jetstream_cluster.go removePeer.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier to remove.</param>
|
||||
public bool RemovePeer(string peerId)
|
||||
{
|
||||
var removed = Peers.Remove(peerId);
|
||||
@@ -77,6 +107,7 @@ public sealed class RaftGroup
|
||||
/// Adds a peer to the group if not already present. Returns true if the peer was added.
|
||||
/// Go reference: jetstream_cluster.go addPeer / expandGroup.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier to add.</param>
|
||||
public bool AddPeer(string peerId)
|
||||
{
|
||||
if (IsMember(peerId))
|
||||
@@ -92,6 +123,10 @@ public sealed class RaftGroup
|
||||
/// Go reference: jetstream_cluster.go createGroupForStream — calls selectPeerGroup then
|
||||
/// assigns rg.DesiredReplicas = replicas.
|
||||
/// </summary>
|
||||
/// <param name="groupName">Name for the new raft group.</param>
|
||||
/// <param name="replicas">Requested replica count.</param>
|
||||
/// <param name="availablePeers">Candidate peers available for placement.</param>
|
||||
/// <param name="policy">Optional placement policy constraints.</param>
|
||||
public static RaftGroup CreateRaftGroup(
|
||||
string groupName,
|
||||
int replicas,
|
||||
@@ -110,13 +145,44 @@ public sealed class RaftGroup
|
||||
/// </summary>
|
||||
public sealed class StreamAssignment
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets stream name for this assignment.
|
||||
/// </summary>
|
||||
public required string StreamName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets raft group owning stream replicas.
|
||||
/// </summary>
|
||||
public required RaftGroup Group { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets stream assignment creation time.
|
||||
/// </summary>
|
||||
public DateTime Created { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets serialized stream config snapshot.
|
||||
/// </summary>
|
||||
public string ConfigJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets synchronization subject used by assignment workflows.
|
||||
/// </summary>
|
||||
public string SyncSubject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether assignment response has been observed.
|
||||
/// </summary>
|
||||
public bool Responded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether stream is recovering.
|
||||
/// </summary>
|
||||
public bool Recovering { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether stream is currently being reassigned.
|
||||
/// </summary>
|
||||
public bool Reassigning { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -144,12 +210,39 @@ public sealed class StreamAssignment
|
||||
/// </summary>
|
||||
public sealed class ConsumerAssignment
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets consumer name.
|
||||
/// </summary>
|
||||
public required string ConsumerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets parent stream name.
|
||||
/// </summary>
|
||||
public required string StreamName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets raft group owning consumer state.
|
||||
/// </summary>
|
||||
public required RaftGroup Group { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets consumer assignment creation time.
|
||||
/// </summary>
|
||||
public DateTime Created { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets serialized consumer config snapshot.
|
||||
/// </summary>
|
||||
public string ConfigJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether assignment response has been observed.
|
||||
/// </summary>
|
||||
public bool Responded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether consumer is recovering.
|
||||
/// </summary>
|
||||
public bool Recovering { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -43,11 +43,20 @@ public sealed class JetStreamMetaGroup
|
||||
// Go reference: jetstream_cluster.go — forward-compatibility skip counter.
|
||||
private int _skippedUnsupportedEntries;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a meta group model with a fixed cluster size and default local index.
|
||||
/// </summary>
|
||||
/// <param name="nodes">Configured number of meta-group nodes in the cluster.</param>
|
||||
public JetStreamMetaGroup(int nodes)
|
||||
: this(nodes, selfIndex: 1)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a meta group model with an explicit local node index.
|
||||
/// </summary>
|
||||
/// <param name="nodes">Configured number of meta-group nodes in the cluster.</param>
|
||||
/// <param name="selfIndex">Local node index used for leader comparisons.</param>
|
||||
public JetStreamMetaGroup(int nodes, int selfIndex)
|
||||
{
|
||||
_nodes = nodes;
|
||||
@@ -117,6 +126,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Increments OpsCount on duplicate proposals for the same stream name.
|
||||
/// Go reference: jetstream_cluster.go inflight proposal tracking.
|
||||
/// </summary>
|
||||
/// <param name="account">Account scope for the inflight stream proposal.</param>
|
||||
/// <param name="sa">Proposed stream assignment being tracked.</param>
|
||||
public void TrackInflightStreamProposal(string account, StreamAssignment sa)
|
||||
{
|
||||
var accountDict = _inflightStreams.GetOrAdd(account, _ => new Dictionary<string, InflightInfo>(StringComparer.Ordinal));
|
||||
@@ -134,6 +145,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Removes the account entry when its dictionary becomes empty.
|
||||
/// Go reference: jetstream_cluster.go inflight proposal tracking.
|
||||
/// </summary>
|
||||
/// <param name="account">Account scope for the inflight stream proposal.</param>
|
||||
/// <param name="streamName">Stream name whose inflight tracker should be decremented.</param>
|
||||
public void RemoveInflightStreamProposal(string account, string streamName)
|
||||
{
|
||||
if (!_inflightStreams.TryGetValue(account, out var accountDict))
|
||||
@@ -161,6 +174,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns true if the given stream is currently tracked as inflight for the account.
|
||||
/// Go reference: jetstream_cluster.go inflight check.
|
||||
/// </summary>
|
||||
/// <param name="account">Account scope to check.</param>
|
||||
/// <param name="streamName">Stream name to check for inflight presence.</param>
|
||||
public bool IsStreamInflight(string account, string streamName)
|
||||
{
|
||||
if (!_inflightStreams.TryGetValue(account, out var accountDict))
|
||||
@@ -177,6 +192,10 @@ public sealed class JetStreamMetaGroup
|
||||
/// Increments OpsCount on duplicate proposals for the same stream/consumer key.
|
||||
/// Go reference: jetstream_cluster.go inflight consumer proposal tracking.
|
||||
/// </summary>
|
||||
/// <param name="account">Account scope for the inflight consumer proposal.</param>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name under the stream.</param>
|
||||
/// <param name="ca">Optional consumer assignment payload for future reconciliation.</param>
|
||||
public void TrackInflightConsumerProposal(string account, string streamName, string consumerName, ConsumerAssignment? ca = null)
|
||||
{
|
||||
var key = $"{streamName}/{consumerName}";
|
||||
@@ -195,6 +214,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Removes the account entry when its dictionary becomes empty.
|
||||
/// Go reference: jetstream_cluster.go inflight consumer proposal tracking.
|
||||
/// </summary>
|
||||
/// <param name="account">Account scope for the inflight consumer proposal.</param>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name whose inflight tracker should be decremented.</param>
|
||||
public void RemoveInflightConsumerProposal(string account, string streamName, string consumerName)
|
||||
{
|
||||
var key = $"{streamName}/{consumerName}";
|
||||
@@ -223,6 +245,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns true if the given consumer is currently tracked as inflight for the account.
|
||||
/// Go reference: jetstream_cluster.go inflight check.
|
||||
/// </summary>
|
||||
/// <param name="account">Account scope to check.</param>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to check for inflight presence.</param>
|
||||
public bool IsConsumerInflight(string account, string streamName, string consumerName)
|
||||
{
|
||||
var key = $"{streamName}/{consumerName}";
|
||||
@@ -254,6 +279,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// and the full assignment map.
|
||||
/// Go reference: jetstream_cluster.go processStreamAssignment.
|
||||
/// </summary>
|
||||
/// <param name="config">Stream configuration containing stream identity and limits.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeCreateStreamAsync(StreamConfig config, CancellationToken ct)
|
||||
=> ProposeCreateStreamAsync(config, group: null, ct);
|
||||
|
||||
@@ -262,6 +289,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Idempotent: duplicate creates for the same name are silently ignored.
|
||||
/// Go reference: jetstream_cluster.go processStreamAssignment.
|
||||
/// </summary>
|
||||
/// <param name="config">Stream configuration containing stream identity and limits.</param>
|
||||
/// <param name="group">Optional explicit raft group placement for the stream.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeCreateStreamAsync(StreamConfig config, RaftGroup? group, CancellationToken ct)
|
||||
{
|
||||
_ = ct;
|
||||
@@ -285,6 +315,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Use this method when the caller needs strict validation (e.g. API layer).
|
||||
/// Go reference: jetstream_cluster.go processStreamAssignment with validation.
|
||||
/// </summary>
|
||||
/// <param name="config">Stream configuration containing stream identity and limits.</param>
|
||||
/// <param name="group">Optional explicit raft group placement for the stream.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeCreateStreamValidatedAsync(StreamConfig config, RaftGroup? group, CancellationToken ct)
|
||||
{
|
||||
_ = ct;
|
||||
@@ -313,6 +346,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Proposes deleting a stream. Removes from both tracking structures.
|
||||
/// Go reference: jetstream_cluster.go processStreamDelete.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Name of the stream to delete.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeDeleteStreamAsync(string streamName, CancellationToken ct)
|
||||
{
|
||||
_ = ct;
|
||||
@@ -324,6 +359,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Proposes deleting a stream with leader validation.
|
||||
/// Go reference: jetstream_cluster.go processStreamDelete with leader check.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Name of the stream to delete.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeDeleteStreamValidatedAsync(string streamName, CancellationToken ct)
|
||||
{
|
||||
_ = ct;
|
||||
@@ -344,6 +381,10 @@ public sealed class JetStreamMetaGroup
|
||||
/// If the stream does not exist, the consumer is silently not tracked.
|
||||
/// Go reference: jetstream_cluster.go processConsumerAssignment.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to create.</param>
|
||||
/// <param name="group">Raft group assignment for the consumer state.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeCreateConsumerAsync(
|
||||
string streamName,
|
||||
string consumerName,
|
||||
@@ -369,6 +410,10 @@ public sealed class JetStreamMetaGroup
|
||||
/// Use this method when the caller needs strict validation (e.g. API layer).
|
||||
/// Go reference: jetstream_cluster.go processConsumerAssignment with validation.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to create.</param>
|
||||
/// <param name="group">Raft group assignment for the consumer state.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeCreateConsumerValidatedAsync(
|
||||
string streamName,
|
||||
string consumerName,
|
||||
@@ -400,6 +445,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Silently does nothing if stream or consumer does not exist.
|
||||
/// Go reference: jetstream_cluster.go processConsumerDelete.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to delete.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeDeleteConsumerAsync(
|
||||
string streamName,
|
||||
string consumerName,
|
||||
@@ -414,6 +462,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Proposes deleting a consumer with leader validation.
|
||||
/// Go reference: jetstream_cluster.go processConsumerDelete with leader check.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to delete.</param>
|
||||
/// <param name="ct">Cancellation token for the proposal request.</param>
|
||||
public Task ProposeDeleteConsumerValidatedAsync(
|
||||
string streamName,
|
||||
string consumerName,
|
||||
@@ -441,6 +492,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Idempotent: duplicate assignments for the same stream name are accepted.
|
||||
/// Go reference: jetstream_cluster.go:4541 processStreamAssignment.
|
||||
/// </summary>
|
||||
/// <param name="sa">Stream assignment entry received from replicated meta log.</param>
|
||||
public bool ProcessStreamAssignment(StreamAssignment sa)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sa.StreamName) || sa.Group == null)
|
||||
@@ -464,6 +516,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns false if the stream does not exist.
|
||||
/// Go reference: jetstream_cluster.go processUpdateStreamAssignment.
|
||||
/// </summary>
|
||||
/// <param name="sa">Updated stream assignment payload.</param>
|
||||
public bool ProcessUpdateStreamAssignment(StreamAssignment sa)
|
||||
{
|
||||
if (!_assignments.TryGetValue(sa.StreamName, out var existing))
|
||||
@@ -491,6 +544,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns false if stream didn't exist. Returns true if removed.
|
||||
/// Go reference: jetstream_cluster.go processStreamRemoval.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream name to remove from assignment state.</param>
|
||||
public bool ProcessStreamRemoval(string streamName)
|
||||
{
|
||||
if (!_assignments.ContainsKey(streamName))
|
||||
@@ -507,6 +561,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Version 0 is treated as version 1 for backward compatibility with pre-versioned entries.
|
||||
/// Go reference: jetstream_cluster.go:5300 processConsumerAssignment.
|
||||
/// </summary>
|
||||
/// <param name="ca">Consumer assignment entry received from replicated meta log.</param>
|
||||
public bool ProcessConsumerAssignment(ConsumerAssignment ca)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ca.ConsumerName) || string.IsNullOrEmpty(ca.StreamName))
|
||||
@@ -533,6 +588,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns false if stream or consumer doesn't exist.
|
||||
/// Go reference: jetstream_cluster.go processConsumerRemoval.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to remove.</param>
|
||||
public bool ProcessConsumerRemoval(string streamName, string consumerName)
|
||||
{
|
||||
if (!_assignments.TryGetValue(streamName, out var sa))
|
||||
@@ -554,6 +611,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Directly adds a stream assignment to the meta-group state.
|
||||
/// Used by the cluster monitor when processing RAFT entries.
|
||||
/// </summary>
|
||||
/// <param name="sa">Stream assignment to add/update.</param>
|
||||
public void AddStreamAssignment(StreamAssignment sa)
|
||||
{
|
||||
_streams[sa.StreamName] = 0;
|
||||
@@ -564,6 +622,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Removes a stream assignment from the meta-group state.
|
||||
/// Used by the cluster monitor when processing RAFT entries.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream name to remove.</param>
|
||||
public void RemoveStreamAssignment(string streamName)
|
||||
{
|
||||
ApplyStreamDelete(streamName);
|
||||
@@ -573,6 +632,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Adds a consumer assignment to a stream's assignment.
|
||||
/// Increments the total consumer count if the consumer is new.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="ca">Consumer assignment to add/update.</param>
|
||||
public void AddConsumerAssignment(string streamName, ConsumerAssignment ca)
|
||||
{
|
||||
if (_assignments.TryGetValue(streamName, out var sa))
|
||||
@@ -587,6 +648,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// <summary>
|
||||
/// Removes a consumer assignment from a stream.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name for the consumer.</param>
|
||||
/// <param name="consumerName">Consumer name to remove.</param>
|
||||
public void RemoveConsumerAssignment(string streamName, string consumerName)
|
||||
{
|
||||
ApplyConsumerDelete(streamName, consumerName);
|
||||
@@ -596,6 +659,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Replaces all assignments atomically (used for snapshot apply).
|
||||
/// Go reference: jetstream_cluster.go meta snapshot restore.
|
||||
/// </summary>
|
||||
/// <param name="newState">Complete replacement assignment map from snapshot state.</param>
|
||||
public void ReplaceAllAssignments(Dictionary<string, StreamAssignment> newState)
|
||||
{
|
||||
_assignments.Clear();
|
||||
@@ -620,6 +684,10 @@ public sealed class JetStreamMetaGroup
|
||||
/// Dispatches based on entry type prefix.
|
||||
/// Go reference: jetstream_cluster.go processStreamAssignment / processConsumerAssignment.
|
||||
/// </summary>
|
||||
/// <param name="entryType">Entry operation kind to apply.</param>
|
||||
/// <param name="name">Primary entity name (stream/consumer/peer depending on entry type).</param>
|
||||
/// <param name="streamName">Parent stream name for consumer entry types.</param>
|
||||
/// <param name="group">Optional raft group payload for create entry types.</param>
|
||||
public void ApplyEntry(MetaEntryType entryType, string name, string? streamName = null, RaftGroup? group = null)
|
||||
{
|
||||
switch (entryType)
|
||||
@@ -665,6 +733,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns the StreamAssignment for the given stream name, or null if not found.
|
||||
/// Go reference: jetstream_cluster.go streamAssignment lookup in meta leader.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream name to resolve.</param>
|
||||
public StreamAssignment? GetStreamAssignment(string streamName)
|
||||
=> _assignments.TryGetValue(streamName, out var assignment) ? assignment : null;
|
||||
|
||||
@@ -672,6 +741,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns the ConsumerAssignment for the given stream and consumer, or null if not found.
|
||||
/// Go reference: jetstream_cluster.go consumerAssignment lookup.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Parent stream name.</param>
|
||||
/// <param name="consumerName">Consumer name within the stream.</param>
|
||||
public ConsumerAssignment? GetConsumerAssignment(string streamName, string consumerName)
|
||||
{
|
||||
if (_assignments.TryGetValue(streamName, out var sa)
|
||||
@@ -694,6 +765,9 @@ public sealed class JetStreamMetaGroup
|
||||
// State
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns a point-in-time snapshot of meta-group topology and assignment counts.
|
||||
/// </summary>
|
||||
public MetaGroupState GetState()
|
||||
{
|
||||
return new MetaGroupState
|
||||
@@ -719,6 +793,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// When becoming leader: fires OnLeaderChange event.
|
||||
/// Go reference: jetstream_cluster.go:7001-7074 processLeaderChange.
|
||||
/// </summary>
|
||||
/// <param name="isLeader">`true` when this node became leader; `false` when stepping down.</param>
|
||||
public void ProcessLeaderChange(bool isLeader)
|
||||
{
|
||||
if (!isLeader)
|
||||
@@ -756,6 +831,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Registers a peer as known to this meta-group.
|
||||
/// Go reference: jetstream_cluster.go peer tracking in jetStreamCluster.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier to register.</param>
|
||||
public void AddKnownPeer(string peerId)
|
||||
{
|
||||
lock (_knownPeers)
|
||||
@@ -766,6 +842,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// Removes a peer from the known-peers set.
|
||||
/// Go reference: jetstream_cluster.go peer removal tracking.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier to remove.</param>
|
||||
public void RemoveKnownPeer(string peerId)
|
||||
{
|
||||
lock (_knownPeers)
|
||||
@@ -787,6 +864,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// and adds the new peer to their RaftGroup, triggering re-replication.
|
||||
/// Go reference: jetstream_cluster.go:2290 processAddPeer.
|
||||
/// </summary>
|
||||
/// <param name="peerId">New peer identifier that joined the cluster.</param>
|
||||
public void ProcessAddPeer(string peerId)
|
||||
{
|
||||
// Always register the new peer.
|
||||
@@ -824,6 +902,7 @@ public sealed class JetStreamMetaGroup
|
||||
/// triggers reassignment away from that peer.
|
||||
/// Go reference: jetstream_cluster.go:2342 processRemovePeer.
|
||||
/// </summary>
|
||||
/// <param name="peerId">Peer identifier removed from the cluster.</param>
|
||||
public void ProcessRemovePeer(string peerId)
|
||||
{
|
||||
// Always remove from known set.
|
||||
@@ -846,6 +925,8 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns true if a replacement peer was found; false if the peer list was merely shrunk.
|
||||
/// Go reference: jetstream_cluster.go:2403 removePeerFromStreamLocked.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream whose raft peer set should be remapped.</param>
|
||||
/// <param name="peerId">Peer identifier to remove.</param>
|
||||
public bool RemovePeerFromStream(string streamName, string peerId)
|
||||
{
|
||||
if (!_assignments.TryGetValue(streamName, out var sa))
|
||||
@@ -869,6 +950,9 @@ public sealed class JetStreamMetaGroup
|
||||
/// Returns true when a replacement peer was placed; false if the group was merely shrunk.
|
||||
/// Go reference: jetstream_cluster.go:7077 remapStreamAssignment.
|
||||
/// </summary>
|
||||
/// <param name="assignment">Stream assignment to mutate.</param>
|
||||
/// <param name="availablePeers">Available peer pool that can host replicas.</param>
|
||||
/// <param name="removePeer">Peer identifier to remove from the assignment.</param>
|
||||
public bool RemapStreamAssignment(StreamAssignment assignment, IReadOnlyList<string> availablePeers, string removePeer)
|
||||
{
|
||||
var group = assignment.Group;
|
||||
@@ -991,9 +1075,24 @@ public enum MetaEntryType
|
||||
|
||||
public sealed class MetaGroupState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets stream names currently tracked by the meta group.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Streams { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets configured cluster size for this meta group model.
|
||||
/// </summary>
|
||||
public int ClusterSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets current leader identifier string.
|
||||
/// </summary>
|
||||
public string LeaderId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets leadership epoch/version used to detect leader transitions.
|
||||
/// </summary>
|
||||
public long LeadershipVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -20,8 +20,19 @@ public sealed class StreamReplicaGroup
|
||||
// Last consumer op applied (used for diagnostics / unknown-op logging).
|
||||
private string _lastUnknownCommand = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream name owned by this replica group.
|
||||
/// </summary>
|
||||
public string StreamName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets raft nodes participating in this replica group.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RaftNode> Nodes => _nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets current leader node for this group.
|
||||
/// </summary>
|
||||
public RaftNode Leader { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -85,6 +96,11 @@ public sealed class StreamReplicaGroup
|
||||
/// <summary>Number of committed entries awaiting state-machine application.</summary>
|
||||
public int PendingCommits => Leader.CommitQueue.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream replica group with generated node IDs.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream name for this replica group.</param>
|
||||
/// <param name="replicas">Requested replica count.</param>
|
||||
public StreamReplicaGroup(string streamName, int replicas)
|
||||
{
|
||||
StreamName = streamName;
|
||||
@@ -106,6 +122,7 @@ public sealed class StreamReplicaGroup
|
||||
/// Go reference: jetstream_cluster.go processStreamAssignment — creates a per-stream
|
||||
/// raft group from the assignment's group peers.
|
||||
/// </summary>
|
||||
/// <param name="assignment">Stream assignment containing peer layout and stream identity.</param>
|
||||
public StreamReplicaGroup(StreamAssignment assignment)
|
||||
{
|
||||
Assignment = assignment;
|
||||
@@ -130,6 +147,11 @@ public sealed class StreamReplicaGroup
|
||||
Leader = ElectLeader(_nodes[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proposes a raw raft command against the current leader.
|
||||
/// </summary>
|
||||
/// <param name="command">Command payload to append to raft log.</param>
|
||||
/// <param name="ct">Cancellation token for proposal operation.</param>
|
||||
public async ValueTask<long> ProposeAsync(string command, CancellationToken ct)
|
||||
{
|
||||
if (!Leader.IsLeader)
|
||||
@@ -143,6 +165,10 @@ public sealed class StreamReplicaGroup
|
||||
/// Encodes subject + payload into a RAFT log entry command.
|
||||
/// Go reference: jetstream_cluster.go processStreamMsg.
|
||||
/// </summary>
|
||||
/// <param name="subject">Message subject.</param>
|
||||
/// <param name="headers">Message header bytes.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
/// <param name="ct">Cancellation token for proposal operation.</param>
|
||||
public async ValueTask<long> ProposeMessageAsync(
|
||||
string subject, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
@@ -159,6 +185,10 @@ public sealed class StreamReplicaGroup
|
||||
return index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces current leader to step down and elects the next candidate.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token for API symmetry; not consumed.</param>
|
||||
public Task StepDownAsync(CancellationToken ct)
|
||||
{
|
||||
_ = ct;
|
||||
@@ -188,6 +218,11 @@ public sealed class StreamReplicaGroup
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a new replica placement size by growing or shrinking node list.
|
||||
/// </summary>
|
||||
/// <param name="placement">Placement vector whose length determines target replica count.</param>
|
||||
/// <param name="ct">Cancellation token for API symmetry; not consumed.</param>
|
||||
public Task ApplyPlacementAsync(IReadOnlyList<int> placement, CancellationToken ct)
|
||||
{
|
||||
_ = ct;
|
||||
@@ -225,6 +260,7 @@ public sealed class StreamReplicaGroup
|
||||
/// anything else — marks the entry as processed via MarkProcessed
|
||||
/// Go reference: jetstream_cluster.go:processStreamEntries (apply loop).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token for async peer proposal operations.</param>
|
||||
public async Task ApplyCommittedEntriesAsync(CancellationToken ct)
|
||||
{
|
||||
while (Leader.CommitQueue.TryDequeue(out var entry))
|
||||
@@ -272,6 +308,8 @@ public sealed class StreamReplicaGroup
|
||||
/// Applies a stream-level message operation (Store, Remove, Purge) to the local state.
|
||||
/// Go reference: jetstream_cluster.go:2474-4261 processStreamEntries — per-message ops.
|
||||
/// </summary>
|
||||
/// <param name="op">Stream message operation to apply.</param>
|
||||
/// <param name="index">Optional raft index used for sequence advancement.</param>
|
||||
public void ApplyStreamMsgOp(StreamMsgOp op, long index = 0)
|
||||
{
|
||||
switch (op)
|
||||
@@ -305,6 +343,7 @@ public sealed class StreamReplicaGroup
|
||||
/// Applies a consumer state entry (Ack, Nak, Deliver, Term, Progress).
|
||||
/// Go reference: jetstream_cluster.go processConsumerEntries.
|
||||
/// </summary>
|
||||
/// <param name="op">Consumer operation to apply.</param>
|
||||
public void ApplyConsumerEntry(ConsumerOp op)
|
||||
{
|
||||
switch (op)
|
||||
@@ -356,6 +395,7 @@ public sealed class StreamReplicaGroup
|
||||
/// the log up to that point.
|
||||
/// Go reference: raft.go CreateSnapshotCheckpoint.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token for checkpoint operation.</param>
|
||||
public Task<RaftSnapshot> CheckpointAsync(CancellationToken ct)
|
||||
=> Leader.CreateSnapshotCheckpointAsync(ct);
|
||||
|
||||
@@ -364,6 +404,8 @@ public sealed class StreamReplicaGroup
|
||||
/// commit-queue entries before applying the snapshot state.
|
||||
/// Go reference: raft.go DrainAndReplaySnapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Snapshot payload to restore from.</param>
|
||||
/// <param name="ct">Cancellation token for restore operation.</param>
|
||||
public Task RestoreFromSnapshotAsync(RaftSnapshot snapshot, CancellationToken ct)
|
||||
=> Leader.DrainAndReplaySnapshotAsync(snapshot, ct);
|
||||
|
||||
@@ -416,13 +458,44 @@ public sealed class StreamReplicaGroup
|
||||
/// </summary>
|
||||
public sealed class StreamReplicaStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets stream name for this status snapshot.
|
||||
/// </summary>
|
||||
public string StreamName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets leader node identifier.
|
||||
/// </summary>
|
||||
public string LeaderId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets current leader term.
|
||||
/// </summary>
|
||||
public int LeaderTerm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets applied message count.
|
||||
/// </summary>
|
||||
public long MessageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets last applied sequence value.
|
||||
/// </summary>
|
||||
public long LastSequence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets replica count in the group.
|
||||
/// </summary>
|
||||
public int ReplicaCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets committed raft index.
|
||||
/// </summary>
|
||||
public long CommitIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets applied raft index.
|
||||
/// </summary>
|
||||
public long AppliedIndex { get; init; }
|
||||
}
|
||||
|
||||
@@ -431,8 +504,19 @@ public sealed class StreamReplicaStatus
|
||||
/// </summary>
|
||||
public sealed class LeaderChangedEventArgs(string previousLeaderId, string newLeaderId, int newTerm) : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets previous leader identifier.
|
||||
/// </summary>
|
||||
public string PreviousLeaderId { get; } = previousLeaderId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets new leader identifier.
|
||||
/// </summary>
|
||||
public string NewLeaderId { get; } = newLeaderId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets new leader term.
|
||||
/// </summary>
|
||||
public int NewTerm { get; } = newTerm;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,25 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// </summary>
|
||||
public StreamManager? StreamManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the consumer manager for stream-scoped durable/ephemeral consumers.
|
||||
/// </summary>
|
||||
/// <param name="metaGroup">Optional JetStream meta group reference for cluster-aware operations.</param>
|
||||
public ConsumerManager(JetStreamMetaGroup? metaGroup = null)
|
||||
{
|
||||
_metaGroup = metaGroup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of registered consumers across all streams.
|
||||
/// </summary>
|
||||
public int ConsumerCount => _consumers.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new consumer or updates an existing durable consumer configuration.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream for the consumer.</param>
|
||||
/// <param name="config">Requested consumer configuration from the JetStream API request.</param>
|
||||
public JetStreamApiResponse CreateOrUpdate(string stream, ConsumerConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.DurableName))
|
||||
@@ -92,6 +104,11 @@ public sealed class ConsumerManager : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns API info payload for a specific stream consumer.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public JetStreamApiResponse GetInfo(string stream, string durableName)
|
||||
{
|
||||
if (_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -110,15 +127,30 @@ public sealed class ConsumerManager : IDisposable
|
||||
return JetStreamApiResponse.NotFound($"$JS.API.CONSUMER.INFO.{stream}.{durableName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve a consumer handle by stream and durable name.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="handle">Resolved in-memory consumer handle when found.</param>
|
||||
public bool TryGet(string stream, string durableName, out ConsumerHandle handle)
|
||||
=> _consumers.TryGetValue((stream, durableName), out handle!);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a consumer and clears any pending auto-resume timer.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public bool Delete(string stream, string durableName)
|
||||
{
|
||||
CancelResumeTimer((stream, durableName));
|
||||
return _consumers.TryRemove((stream, durableName), out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists consumer durable names for a stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name to list consumers from.</param>
|
||||
public IReadOnlyList<string> ListNames(string stream)
|
||||
=> _consumers.Keys
|
||||
.Where(k => string.Equals(k.Stream, stream, StringComparison.Ordinal))
|
||||
@@ -126,6 +158,10 @@ public sealed class ConsumerManager : IDisposable
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Lists API consumer info objects for a stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name to list consumer details from.</param>
|
||||
public IReadOnlyList<JetStreamConsumerInfo> ListConsumerInfos(string stream)
|
||||
=> _consumers
|
||||
.Where(kv => string.Equals(kv.Key.Stream, stream, StringComparison.Ordinal))
|
||||
@@ -133,6 +169,12 @@ public sealed class ConsumerManager : IDisposable
|
||||
.Select(kv => new JetStreamConsumerInfo { Name = kv.Value.Config.DurableName, StreamName = stream, Config = kv.Value.Config })
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Pauses or unpauses a consumer immediately.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="paused"><see langword="true"/> to pause delivery; <see langword="false"/> to resume immediately.</param>
|
||||
public bool Pause(string stream, string durableName, bool paused)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -152,6 +194,9 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// A background timer will auto-resume the consumer when the deadline passes.
|
||||
/// Go reference: consumer.go (pauseConsumer).
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name containing the consumer.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="pauseUntilUtc">UTC deadline for automatic resume.</param>
|
||||
public bool Pause(string stream, string durableName, DateTime pauseUntilUtc)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -184,6 +229,8 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// Explicitly resume a paused consumer, cancelling any pending auto-resume timer.
|
||||
/// Go reference: consumer.go (resumeConsumer).
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name containing the consumer.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public bool Resume(string stream, string durableName)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -200,6 +247,8 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// If the deadline has passed, auto-resumes the consumer and returns false.
|
||||
/// Go reference: consumer.go (isPaused).
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name containing the consumer.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public bool IsPaused(string stream, string durableName)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -221,6 +270,8 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// Returns the UTC deadline until which the consumer is paused, or null.
|
||||
/// Go reference: consumer.go (pauseUntil).
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name containing the consumer.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public DateTime? GetPauseUntil(string stream, string durableName)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -246,6 +297,9 @@ public sealed class ConsumerManager : IDisposable
|
||||
timer.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes active resume timers and clears timer registry.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var timer in _resumeTimers.Values)
|
||||
@@ -253,6 +307,11 @@ public sealed class ConsumerManager : IDisposable
|
||||
_resumeTimers.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets consumer sequence and pending queue to initial state.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public bool Reset(string stream, string durableName)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -268,6 +327,9 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// Clears pending acks and redelivery state.
|
||||
/// Go reference: consumer.go:4241 processResetReq.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name containing the consumer.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="sequence">Next sequence to resume delivery from.</param>
|
||||
public bool ResetToSequence(string stream, string durableName, ulong sequence)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -285,14 +347,35 @@ public sealed class ConsumerManager : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a consumer exists for unpin-style API semantics.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public bool Unpin(string stream, string durableName)
|
||||
{
|
||||
return _consumers.ContainsKey((stream, durableName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a pull batch using a simple batch-size request.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="batch">Maximum number of messages requested.</param>
|
||||
/// <param name="streamManager">Stream registry used to resolve the source stream handle.</param>
|
||||
/// <param name="ct">Cancellation token for wait and fetch operations.</param>
|
||||
public async ValueTask<PullFetchBatch> FetchAsync(string stream, string durableName, int batch, StreamManager streamManager, CancellationToken ct)
|
||||
=> await FetchAsync(stream, durableName, new PullFetchRequest { Batch = batch }, streamManager, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a pull batch for a consumer using a detailed pull request.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="request">Pull request options such as batch size, expiry, and byte limits.</param>
|
||||
/// <param name="streamManager">Stream registry used to resolve the source stream handle.</param>
|
||||
/// <param name="ct">Cancellation token for wait and fetch operations.</param>
|
||||
public async ValueTask<PullFetchBatch> FetchAsync(string stream, string durableName, PullFetchRequest request, StreamManager streamManager, CancellationToken ct)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var consumer))
|
||||
@@ -304,6 +387,12 @@ public sealed class ConsumerManager : IDisposable
|
||||
return await _pullConsumerEngine.FetchAsync(streamHandle, consumer, request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges all pending entries up to the specified sequence.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
/// <param name="sequence">Inclusive stream sequence that advances the ack floor.</param>
|
||||
public bool AckAll(string stream, string durableName, ulong sequence)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -314,6 +403,11 @@ public sealed class ConsumerManager : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns pending-ack count for a consumer.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public int GetPendingCount(string stream, string durableName)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
@@ -326,9 +420,15 @@ public sealed class ConsumerManager : IDisposable
|
||||
/// Returns true if there are any consumers registered for the given stream.
|
||||
/// Used to short-circuit the LoadAsync call on the publish hot path.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name to check.</param>
|
||||
public bool HasConsumersForStream(string stream)
|
||||
=> _consumers.Keys.Any(k => string.Equals(k.Stream, stream, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Handles a newly stored stream message for push-consumer fan-out.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name for the published message.</param>
|
||||
/// <param name="message">Stored message metadata and payload to fan out.</param>
|
||||
public void OnPublished(string stream, StoredMessage message)
|
||||
{
|
||||
foreach (var handle in _consumers.Values.Where(c => c.Stream == stream && c.Config.Push))
|
||||
@@ -343,6 +443,11 @@ public sealed class ConsumerManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next available push frame for a consumer when release time has arrived.
|
||||
/// </summary>
|
||||
/// <param name="stream">Owning stream name.</param>
|
||||
/// <param name="durableName">Consumer durable name.</param>
|
||||
public PushFrame? ReadPushFrame(string stream, string durableName)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var consumer))
|
||||
@@ -369,6 +474,10 @@ public sealed class ConsumerManager : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream-level ack floor derived from consumer acknowledgements.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name whose ack floor should be returned.</param>
|
||||
internal ulong GetAckFloor(string stream)
|
||||
=> _ackFloors.TryGetValue(stream, out var ackFloor) ? ackFloor : 0;
|
||||
}
|
||||
@@ -384,6 +493,9 @@ public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
|
||||
private Consumers.CompiledFilter? _compiledFilter;
|
||||
private string? _compiledFilterSubject;
|
||||
private int _compiledFilterSubjectsCount;
|
||||
/// <summary>
|
||||
/// Gets cached compiled subject filter for this consumer configuration.
|
||||
/// </summary>
|
||||
public Consumers.CompiledFilter CompiledFilter
|
||||
{
|
||||
get
|
||||
@@ -401,7 +513,14 @@ public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets next stream sequence to deliver.
|
||||
/// </summary>
|
||||
public ulong NextSequence { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether delivery is currently paused.
|
||||
/// </summary>
|
||||
public bool Paused { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -409,9 +528,24 @@ public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
|
||||
/// (until explicitly resumed). Go reference: consumer.go pauseUntil field.
|
||||
/// </summary>
|
||||
public DateTime? PauseUntilUtc { get; set; }
|
||||
/// <summary>
|
||||
/// Gets pending stored messages queued for this consumer.
|
||||
/// </summary>
|
||||
public Queue<StoredMessage> Pending { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets queued push frames waiting for delivery window release.
|
||||
/// </summary>
|
||||
public Queue<PushFrame> PushFrames { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets ack processor state for pending and ack-floor tracking.
|
||||
/// </summary>
|
||||
public AckProcessor AckProcessor { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets next UTC time when push data can be delivered.
|
||||
/// </summary>
|
||||
public DateTime NextPushDataAvailableAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -19,6 +19,10 @@ public sealed class CompiledFilter
|
||||
private readonly string? _singleFilter;
|
||||
private readonly bool _matchAll;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a compiled filter from one or more filter subjects.
|
||||
/// </summary>
|
||||
/// <param name="filterSubjects">Filter subjects from consumer configuration.</param>
|
||||
public CompiledFilter(IReadOnlyList<string> filterSubjects)
|
||||
{
|
||||
if (filterSubjects.Count == 0)
|
||||
@@ -52,6 +56,7 @@ public sealed class CompiledFilter
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the given subject matches any of the compiled filter patterns.
|
||||
/// </summary>
|
||||
/// <param name="subject">Publish subject to evaluate.</param>
|
||||
public bool Matches(string subject)
|
||||
{
|
||||
if (_matchAll)
|
||||
@@ -81,6 +86,7 @@ public sealed class CompiledFilter
|
||||
/// Uses <see cref="ConsumerConfig.FilterSubjects"/> first, falling back to
|
||||
/// <see cref="ConsumerConfig.FilterSubject"/> if the list is empty.
|
||||
/// </summary>
|
||||
/// <param name="config">Consumer configuration source.</param>
|
||||
public static CompiledFilter FromConfig(ConsumerConfig config)
|
||||
{
|
||||
if (config.FilterSubjects.Count > 0)
|
||||
@@ -111,6 +117,8 @@ public sealed class PullConsumerEngine
|
||||
/// Returns true if quorum is available and the request was registered; false otherwise.
|
||||
/// Go reference: consumer.go proposeWaitingRequest — propose via consumer RAFT group.
|
||||
/// </summary>
|
||||
/// <param name="request">Pull request waiting for data.</param>
|
||||
/// <param name="group">Consumer RAFT group used for quorum checks.</param>
|
||||
public bool ProposeWaitingRequest(PullWaitingRequest request, RaftGroup group)
|
||||
{
|
||||
if (!group.HasQuorum(group.Peers.Count))
|
||||
@@ -125,6 +133,7 @@ public sealed class PullConsumerEngine
|
||||
/// Registers a pull request in the cluster pending tracker, keyed by reply subject.
|
||||
/// Go reference: consumer.go — cluster pending registration on proposal acceptance.
|
||||
/// </summary>
|
||||
/// <param name="request">Pull request to register.</param>
|
||||
public void RegisterClusterPending(PullWaitingRequest request)
|
||||
{
|
||||
var replyKey = request.Reply ?? string.Empty;
|
||||
@@ -136,6 +145,7 @@ public sealed class PullConsumerEngine
|
||||
/// Returns null if no request is registered for that reply subject.
|
||||
/// Go reference: consumer.go — cluster pending removal on fulfillment or expiry.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Reply subject key for the pending request.</param>
|
||||
public PullWaitingRequest? RemoveClusterPending(string replySubject)
|
||||
{
|
||||
_clusterPending.TryRemove(replySubject, out var request);
|
||||
@@ -149,9 +159,23 @@ public sealed class PullConsumerEngine
|
||||
public IReadOnlyCollection<PullWaitingRequest> GetClusterPendingRequests()
|
||||
=> _clusterPending.Values.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a pull batch with only a batch-size argument.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream handle to read from.</param>
|
||||
/// <param name="consumer">Consumer handle requesting data.</param>
|
||||
/// <param name="batch">Maximum number of messages requested.</param>
|
||||
/// <param name="ct">Cancellation token for fetch operations.</param>
|
||||
public async ValueTask<PullFetchBatch> FetchAsync(StreamHandle stream, ConsumerHandle consumer, int batch, CancellationToken ct)
|
||||
=> await FetchAsync(stream, consumer, new PullFetchRequest { Batch = batch }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a pull batch using full request options such as timeout and byte limits.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream handle to read from.</param>
|
||||
/// <param name="consumer">Consumer handle requesting data.</param>
|
||||
/// <param name="request">Pull request options.</param>
|
||||
/// <param name="ct">Cancellation token for fetch operations.</param>
|
||||
public async ValueTask<PullFetchBatch> FetchAsync(StreamHandle stream, ConsumerHandle consumer, PullFetchRequest request, CancellationToken ct)
|
||||
{
|
||||
var batch = Math.Max(request.Batch, 1);
|
||||
@@ -356,9 +380,21 @@ public sealed class PullConsumerEngine
|
||||
|
||||
public sealed class PullFetchBatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Messages returned by the fetch operation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether fetch ended due to Expires timeout.
|
||||
/// </summary>
|
||||
public bool TimedOut { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fetch result from returned messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">Messages returned by the pull request.</param>
|
||||
/// <param name="timedOut">Whether request timed out before filling the batch.</param>
|
||||
public PullFetchBatch(IReadOnlyList<StoredMessage> messages, bool timedOut = false)
|
||||
{
|
||||
// Snapshot: caller may reuse the list (ThreadStatic pooling), so take a copy.
|
||||
@@ -369,11 +405,25 @@ public sealed class PullFetchBatch
|
||||
|
||||
public sealed class PullFetchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of messages to return.
|
||||
/// </summary>
|
||||
public int Batch { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// When true, returns immediately if no message is available.
|
||||
/// </summary>
|
||||
public bool NoWait { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum wait time in milliseconds before timing out.
|
||||
/// </summary>
|
||||
public int ExpiresMs { get; init; }
|
||||
// Go: consumer.go — max_bytes limits total bytes per fetch request
|
||||
// Reference: golang/nats-server/server/consumer.go — maxRequestBytes
|
||||
/// <summary>
|
||||
/// Maximum total payload bytes to return in a single fetch.
|
||||
/// </summary>
|
||||
public long MaxBytes { get; init; }
|
||||
}
|
||||
|
||||
@@ -384,8 +434,15 @@ public sealed class PullRequestWaitQueue
|
||||
private readonly int _maxSize;
|
||||
private readonly List<PullWaitingRequest> _items = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bounded queue for pending pull requests.
|
||||
/// </summary>
|
||||
/// <param name="maxSize">Maximum number of queued requests.</param>
|
||||
public PullRequestWaitQueue(int maxSize = int.MaxValue) => _maxSize = maxSize;
|
||||
|
||||
/// <summary>
|
||||
/// Number of pending requests in the queue.
|
||||
/// </summary>
|
||||
public int Count => _items.Count;
|
||||
|
||||
/// <summary>
|
||||
@@ -393,6 +450,7 @@ public sealed class PullRequestWaitQueue
|
||||
/// Returns false if the queue is at capacity.
|
||||
/// Go: consumer.go — waitQueue.addPrioritized with sort.SliceStable semantics.
|
||||
/// </summary>
|
||||
/// <param name="request">Pull request to enqueue.</param>
|
||||
public bool Enqueue(PullWaitingRequest request)
|
||||
{
|
||||
if (_maxSize > 0 && _items.Count >= _maxSize)
|
||||
@@ -412,9 +470,15 @@ public sealed class PullRequestWaitQueue
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next queued request without removing it.
|
||||
/// </summary>
|
||||
public PullWaitingRequest? Peek()
|
||||
=> _items.Count > 0 ? _items[0] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns the next queued request.
|
||||
/// </summary>
|
||||
public PullWaitingRequest? Dequeue()
|
||||
{
|
||||
if (_items.Count == 0) return null;
|
||||
@@ -454,6 +518,10 @@ public sealed class PullRequestWaitQueue
|
||||
return decremented;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to dequeue one request.
|
||||
/// </summary>
|
||||
/// <param name="request">Dequeued request when available.</param>
|
||||
public bool TryDequeue(out PullWaitingRequest? request)
|
||||
{
|
||||
request = Dequeue();
|
||||
@@ -465,10 +533,33 @@ public sealed class PullRequestWaitQueue
|
||||
// Reference: golang/nats-server/server/consumer.go waitingRequest
|
||||
public sealed record PullWaitingRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Priority where lower values are served first.
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Requested batch size.
|
||||
/// </summary>
|
||||
public int Batch { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Remaining messages to deliver for this queued request.
|
||||
/// </summary>
|
||||
public int RemainingBatch { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-request max bytes budget.
|
||||
/// </summary>
|
||||
public long MaxBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiration in milliseconds.
|
||||
/// </summary>
|
||||
public int ExpiresMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reply subject used to track and fulfill the request.
|
||||
/// </summary>
|
||||
public string? Reply { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,62 +1,165 @@
|
||||
namespace NATS.Server.JetStream.Models;
|
||||
|
||||
/// <summary>
|
||||
/// JetStream consumer configuration that controls delivery, acknowledgement, and flow behavior.
|
||||
/// </summary>
|
||||
public sealed class ConsumerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Durable consumer name. Required for durable consumers; generated for ephemerals.
|
||||
/// </summary>
|
||||
public string DurableName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the consumer is ephemeral and may be auto-named by the server.
|
||||
/// </summary>
|
||||
public bool Ephemeral { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Legacy single filter subject used for subject-scoped delivery.
|
||||
/// </summary>
|
||||
public string? FilterSubject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Multi-filter subject list used for subject-scoped delivery.
|
||||
/// </summary>
|
||||
public List<string> FilterSubjects { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledgement policy for delivered messages.
|
||||
/// </summary>
|
||||
public AckPolicy AckPolicy { get; set; } = AckPolicy.None;
|
||||
|
||||
/// <summary>
|
||||
/// Start-position policy used when initializing delivery.
|
||||
/// </summary>
|
||||
public DeliverPolicy DeliverPolicy { get; set; } = DeliverPolicy.All;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit starting sequence used by sequence-based deliver policies.
|
||||
/// </summary>
|
||||
public ulong OptStartSeq { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Explicit UTC start time used by time-based deliver policies.
|
||||
/// </summary>
|
||||
public DateTime? OptStartTimeUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay speed policy for historical messages.
|
||||
/// </summary>
|
||||
public ReplayPolicy ReplayPolicy { get; set; } = ReplayPolicy.Instant;
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledgement wait timeout in milliseconds.
|
||||
/// </summary>
|
||||
public int AckWaitMs { get; set; } = 30_000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delivery attempts per message before it is considered exhausted.
|
||||
/// </summary>
|
||||
public int MaxDeliver { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of unacknowledged messages allowed for this consumer.
|
||||
/// </summary>
|
||||
public int MaxAckPending { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables push delivery mode. When false, the consumer is pull-based.
|
||||
/// </summary>
|
||||
public bool Push { get; set; }
|
||||
// Go: consumer.go:115 — deliver_subject routes push messages to a NATS subject
|
||||
/// <summary>
|
||||
/// Delivery subject used for push consumers.
|
||||
/// </summary>
|
||||
public string DeliverSubject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Idle heartbeat interval in milliseconds for push consumers.
|
||||
/// </summary>
|
||||
public int HeartbeatMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Redelivery backoff schedule in milliseconds.
|
||||
/// </summary>
|
||||
public List<int> BackOffMs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Enables flow control for push delivery.
|
||||
/// </summary>
|
||||
public bool FlowControl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional egress rate limit for delivery, in bits per second.
|
||||
/// </summary>
|
||||
public long RateLimitBps { get; set; }
|
||||
|
||||
// Go: consumer.go — max_waiting limits the number of queued pull requests
|
||||
/// <summary>
|
||||
/// Maximum number of pull requests waiting for data.
|
||||
/// </summary>
|
||||
public int MaxWaiting { get; set; }
|
||||
|
||||
// Go: consumer.go — max_request_batch limits batch size per pull request
|
||||
/// <summary>
|
||||
/// Maximum batch size allowed per pull request.
|
||||
/// </summary>
|
||||
public int MaxRequestBatch { get; set; }
|
||||
|
||||
// Go: consumer.go — max_request_max_bytes limits bytes per pull request
|
||||
/// <summary>
|
||||
/// Maximum bytes allowed per pull request.
|
||||
/// </summary>
|
||||
public int MaxRequestMaxBytes { get; set; }
|
||||
|
||||
// Go: consumer.go — max_request_expires limits expires duration per pull request (ms)
|
||||
/// <summary>
|
||||
/// Maximum request expiration allowed per pull request, in milliseconds.
|
||||
/// </summary>
|
||||
public int MaxRequestExpiresMs { get; set; }
|
||||
|
||||
// Go: ConsumerConfig.PauseUntil — pauses consumer delivery until this UTC time.
|
||||
// Null or zero time means not paused.
|
||||
// Added in v2.11, requires API level 1.
|
||||
// Go reference: server/consumer.go (PauseUntil field)
|
||||
/// <summary>
|
||||
/// UTC time until which consumer delivery is paused.
|
||||
/// </summary>
|
||||
public DateTime? PauseUntil { get; set; }
|
||||
|
||||
// Go: ConsumerConfig.PriorityPolicy — consumer priority routing policy.
|
||||
// PriorityPinnedClient requires API level 1.
|
||||
// Go reference: server/consumer.go (PriorityPolicy field)
|
||||
/// <summary>
|
||||
/// Priority routing policy used when multiple consumers compete for delivery.
|
||||
/// </summary>
|
||||
public PriorityPolicy PriorityPolicy { get; set; } = PriorityPolicy.None;
|
||||
|
||||
// Go: ConsumerConfig.PriorityGroups — list of priority group names.
|
||||
// Go reference: server/consumer.go (PriorityGroups field)
|
||||
/// <summary>
|
||||
/// Priority group names used by priority-based routing.
|
||||
/// </summary>
|
||||
public List<string> PriorityGroups { get; set; } = [];
|
||||
|
||||
// Go: ConsumerConfig.PinnedTTL — TTL for pinned client assignment.
|
||||
// Go reference: server/consumer.go (PinnedTTL field)
|
||||
/// <summary>
|
||||
/// Pinning TTL in milliseconds for pinned-client priority assignments.
|
||||
/// </summary>
|
||||
public long PinnedTtlMs { get; set; }
|
||||
|
||||
// Go: ConsumerConfig.Metadata — user-supplied and server-managed key/value metadata.
|
||||
// Go reference: server/consumer.go (Metadata field)
|
||||
/// <summary>
|
||||
/// Arbitrary metadata associated with the consumer.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the primary filter subject used for APIs that require a single subject.
|
||||
/// </summary>
|
||||
public string? ResolvePrimaryFilterSubject()
|
||||
{
|
||||
if (FilterSubjects.Count > 0)
|
||||
|
||||
@@ -46,11 +46,34 @@ public static class AtomicBatchPublishErrorCodes
|
||||
/// </summary>
|
||||
public sealed class StagedBatchMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Target subject for the staged publish.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload bytes for the staged publish.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional Nats-Msg-Id value used for duplicate detection.
|
||||
/// </summary>
|
||||
public string? MsgId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expected last stream sequence precondition.
|
||||
/// </summary>
|
||||
public ulong ExpectedLastSeq { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expected last sequence precondition for a specific subject.
|
||||
/// </summary>
|
||||
public ulong ExpectedLastSubjectSeq { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject used with <see cref="ExpectedLastSubjectSeq"/> precondition.
|
||||
/// </summary>
|
||||
public string? ExpectedLastSubjectSeqSubject { get; init; }
|
||||
}
|
||||
|
||||
@@ -63,10 +86,25 @@ internal sealed class InFlightBatch
|
||||
private readonly List<StagedBatchMessage> _messages = [];
|
||||
private readonly HashSet<string> _stagedMsgIds = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// UTC creation timestamp used for batch-timeout eviction.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Number of staged messages currently held in the batch.
|
||||
/// </summary>
|
||||
public int Count => _messages.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Staged messages in receive order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StagedBatchMessage> Messages => _messages;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a message to the in-flight batch and tracks Msg-Id for duplicate checks.
|
||||
/// </summary>
|
||||
/// <param name="msg">Message to stage.</param>
|
||||
public void Add(StagedBatchMessage msg)
|
||||
{
|
||||
_messages.Add(msg);
|
||||
@@ -74,6 +112,10 @@ internal sealed class InFlightBatch
|
||||
_stagedMsgIds.Add(msg.MsgId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a message id has already been staged in this batch.
|
||||
/// </summary>
|
||||
/// <param name="msgId">Nats-Msg-Id value to test.</param>
|
||||
public bool ContainsMsgId(string msgId) => _stagedMsgIds.Contains(msgId);
|
||||
}
|
||||
|
||||
@@ -104,6 +146,12 @@ public sealed class AtomicBatchPublishEngine
|
||||
private readonly int _maxBatchSize;
|
||||
private readonly TimeSpan _batchTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an atomic batch engine for one stream.
|
||||
/// </summary>
|
||||
/// <param name="maxInflightPerStream">Maximum concurrently staged batches per stream.</param>
|
||||
/// <param name="maxBatchSize">Maximum number of messages allowed in a batch.</param>
|
||||
/// <param name="batchTimeout">Optional timeout for incomplete staged batches.</param>
|
||||
public AtomicBatchPublishEngine(
|
||||
int maxInflightPerStream = DefaultMaxInflightPerStream,
|
||||
int maxBatchSize = DefaultMaxBatchSize,
|
||||
@@ -123,6 +171,10 @@ public sealed class AtomicBatchPublishEngine
|
||||
/// Validates and stages/commits a batch message.
|
||||
/// Returns a result indicating: stage (empty ack), commit (full ack), or error.
|
||||
/// </summary>
|
||||
/// <param name="req">Parsed batch publish request from headers and payload.</param>
|
||||
/// <param name="preconditions">Duplicate window state and expected-sequence checks.</param>
|
||||
/// <param name="streamDuplicateWindowMs">Duplicate detection window in milliseconds.</param>
|
||||
/// <param name="commitSingle">Callback that commits one staged message to the stream store.</param>
|
||||
public AtomicBatchResult Process(
|
||||
BatchPublishRequest req,
|
||||
PublishPreconditions preconditions,
|
||||
@@ -312,6 +364,7 @@ public sealed class AtomicBatchPublishEngine
|
||||
/// <summary>
|
||||
/// Returns whether a batch with the given ID is currently in-flight.
|
||||
/// </summary>
|
||||
/// <param name="batchId">Batch identifier from Nats-Batch-Id header.</param>
|
||||
public bool HasBatch(string batchId) => _batches.ContainsKey(batchId);
|
||||
|
||||
private void EvictExpiredBatches()
|
||||
@@ -330,9 +383,24 @@ public sealed class AtomicBatchPublishEngine
|
||||
/// </summary>
|
||||
public sealed class BatchPublishRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Batch identifier from Nats-Batch-Id.
|
||||
/// </summary>
|
||||
public required string BatchId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sequence position inside the batch from Nats-Batch-Sequence.
|
||||
/// </summary>
|
||||
public required ulong BatchSeq { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target subject for this batch entry.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload bytes for this batch entry.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
@@ -346,9 +414,24 @@ public sealed class BatchPublishRequest
|
||||
/// </summary>
|
||||
public string? CommitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional Nats-Msg-Id used for duplicate detection.
|
||||
/// </summary>
|
||||
public string? MsgId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected last stream sequence precondition.
|
||||
/// </summary>
|
||||
public ulong ExpectedLastSeq { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected last subject sequence precondition.
|
||||
/// </summary>
|
||||
public ulong ExpectedLastSubjectSeq { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject used with <see cref="ExpectedLastSubjectSeq"/> precondition.
|
||||
/// </summary>
|
||||
public string? ExpectedLastSubjectSeqSubject { get; init; }
|
||||
}
|
||||
|
||||
@@ -359,16 +442,43 @@ public sealed class AtomicBatchResult
|
||||
{
|
||||
public enum ResultKind { Staged, Committed, Error }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome kind for processing: staged, committed, or error.
|
||||
/// </summary>
|
||||
public ResultKind Kind { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Commit acknowledgement when <see cref="Kind"/> is committed.
|
||||
/// </summary>
|
||||
public PubAck? CommitAck { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// JetStream error code when <see cref="Kind"/> is error.
|
||||
/// </summary>
|
||||
public int ErrorCode { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error description when <see cref="Kind"/> is error.
|
||||
/// </summary>
|
||||
public string ErrorDescription { get; private init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a staged result for non-commit batch entries.
|
||||
/// </summary>
|
||||
public static AtomicBatchResult Staged() => new() { Kind = ResultKind.Staged };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a committed result with final publish ack.
|
||||
/// </summary>
|
||||
/// <param name="ack">Ack returned by the final committed message.</param>
|
||||
public static AtomicBatchResult Committed(PubAck ack) =>
|
||||
new() { Kind = ResultKind.Committed, CommitAck = ack };
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error result with code and description.
|
||||
/// </summary>
|
||||
/// <param name="code">JetStream error code.</param>
|
||||
/// <param name="description">Error message.</param>
|
||||
public static AtomicBatchResult Error(int code, string description) =>
|
||||
new() { Kind = ResultKind.Error, ErrorCode = code, ErrorDescription = description };
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ public sealed class MsgBlock : IDisposable
|
||||
private int _pendingBufUsed;
|
||||
private long _pendingBufDiskOffset; // Disk offset corresponding to _pendingBuf[0]
|
||||
|
||||
// Double-buffer for FlushPending: swap _pendingBuf with _flushBuf under lock,
|
||||
// then write _flushBuf to disk without holding the lock. This eliminates
|
||||
// contention between WriteAt (appends to _pendingBuf) and FlushPending (disk I/O).
|
||||
private byte[] _flushBuf = new byte[64 * 1024];
|
||||
private readonly object _flushLock = new(); // Serializes concurrent FlushPending calls
|
||||
|
||||
// Go: msgBlock.lchk — last written record checksum (XxHash64, 8 bytes).
|
||||
// Tracked so callers can chain checksum verification across blocks.
|
||||
// Reference: golang/nats-server/server/filestore.go:2204 (lchk field)
|
||||
@@ -358,16 +364,22 @@ public sealed class MsgBlock : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Flushes the contiguous pending buffer to disk in a single write.
|
||||
/// Must be called while holding the write lock.
|
||||
/// Must be called while holding the write lock. Also acquires _flushLock
|
||||
/// to wait for any in-flight double-buffer flush to complete first.
|
||||
/// </summary>
|
||||
private void FlushPendingBufToDisk()
|
||||
{
|
||||
if (_pendingBufUsed == 0)
|
||||
return;
|
||||
// Wait for any in-flight double-buffer FlushPending to finish writing,
|
||||
// so that disk offsets are consistent before we write more data.
|
||||
lock (_flushLock)
|
||||
{
|
||||
if (_pendingBufUsed == 0)
|
||||
return;
|
||||
|
||||
RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset);
|
||||
_pendingBufDiskOffset += _pendingBufUsed;
|
||||
_pendingBufUsed = 0;
|
||||
RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset);
|
||||
_pendingBufDiskOffset += _pendingBufUsed;
|
||||
_pendingBufUsed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -571,6 +583,8 @@ public sealed class MsgBlock : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all buffered (pending) writes to disk in a single batch.
|
||||
/// Uses double-buffering: swaps the pending buffer under the write lock (fast),
|
||||
/// then writes the old buffer to disk outside the lock so WriteAt is not blocked.
|
||||
/// Called by the background flush loop in FileStore, or synchronously on
|
||||
/// block seal / dispose to ensure all data reaches disk.
|
||||
/// Reference: golang/nats-server/server/filestore.go:7592 (flushPendingMsgsLocked).
|
||||
@@ -581,30 +595,52 @@ public sealed class MsgBlock : IDisposable
|
||||
if (_disposed)
|
||||
return 0;
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Block was disposed concurrently (e.g. during PurgeAsync).
|
||||
return 0;
|
||||
}
|
||||
int bytesToFlush;
|
||||
byte[] bufToFlush;
|
||||
long diskOffset;
|
||||
|
||||
try
|
||||
// Serialize concurrent FlushPending calls (e.g. flush loop + RotateBlock).
|
||||
lock (_flushLock)
|
||||
{
|
||||
if (_pendingBufUsed == 0)
|
||||
// Phase 1: Swap buffers under the write lock (fast — no I/O).
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Single contiguous write — Go: flushPendingMsgsLocked writes cache.buf[wp:] to disk.
|
||||
RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset);
|
||||
try
|
||||
{
|
||||
if (_pendingBufUsed == 0)
|
||||
return 0;
|
||||
|
||||
var flushed = _pendingBufUsed;
|
||||
_pendingBufDiskOffset += _pendingBufUsed;
|
||||
_pendingBufUsed = 0;
|
||||
return flushed;
|
||||
bytesToFlush = _pendingBufUsed;
|
||||
diskOffset = _pendingBufDiskOffset;
|
||||
|
||||
// Swap: _flushBuf becomes the new (empty) pending buffer,
|
||||
// old _pendingBuf (with data) goes to bufToFlush for disk write.
|
||||
bufToFlush = _pendingBuf;
|
||||
_pendingBuf = _flushBuf.Length >= bufToFlush.Length ? _flushBuf : new byte[bufToFlush.Length];
|
||||
_pendingBufUsed = 0;
|
||||
_pendingBufDiskOffset += bytesToFlush;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
// Phase 2: Write to disk without holding the write lock.
|
||||
// WriteAt can proceed concurrently on the new _pendingBuf.
|
||||
RandomAccess.Write(_handle, bufToFlush.AsSpan(0, bytesToFlush), diskOffset);
|
||||
|
||||
// Recycle the flushed buffer for next swap.
|
||||
_flushBuf = bufToFlush;
|
||||
}
|
||||
finally { _lock.ExitWriteLock(); }
|
||||
|
||||
return bytesToFlush;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -31,6 +31,13 @@ public sealed class StreamManager : IDisposable
|
||||
private readonly string? _storeDir;
|
||||
private Task? _expiryTimerTask;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream manager responsible for JetStream stream lifecycle, storage, and replication wiring.
|
||||
/// </summary>
|
||||
/// <param name="metaGroup">Optional cluster meta-group coordinator used for stream proposals.</param>
|
||||
/// <param name="account">Optional account owner used for stream quota accounting.</param>
|
||||
/// <param name="consumerManager">Optional consumer manager used for retention behaviors that depend on ack floors.</param>
|
||||
/// <param name="storeDir">Optional root directory for file-backed stream storage.</param>
|
||||
public StreamManager(JetStreamMetaGroup? metaGroup = null, Account? account = null, ConsumerManager? consumerManager = null, string? storeDir = null)
|
||||
{
|
||||
_metaGroup = metaGroup;
|
||||
@@ -40,6 +47,9 @@ public sealed class StreamManager : IDisposable
|
||||
_expiryTimerTask = RunExpiryTimerAsync(_expiryTimerCts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops background expiry processing and releases manager resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimerCts.Cancel();
|
||||
@@ -77,12 +87,25 @@ public sealed class StreamManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot of registered stream names.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> StreamNames => _streams.Keys.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets current JetStream meta-group state when clustering is enabled.
|
||||
/// </summary>
|
||||
public MetaGroupState? GetMetaState() => _metaGroup?.GetState();
|
||||
|
||||
/// <summary>
|
||||
/// Lists stream names sorted in ordinal order for deterministic API responses.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ListNames()
|
||||
=> [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)];
|
||||
|
||||
/// <summary>
|
||||
/// Lists stream info payloads including current storage state for each stream.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JetStreamStreamInfo> ListStreamInfos()
|
||||
{
|
||||
return _streams.OrderBy(kv => kv.Key, StringComparer.Ordinal)
|
||||
@@ -98,6 +121,10 @@ public sealed class StreamManager : IDisposable
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new stream or updates an existing stream after validating JetStream invariants.
|
||||
/// </summary>
|
||||
/// <param name="config">Requested stream configuration.</param>
|
||||
public JetStreamApiResponse CreateOrUpdate(StreamConfig config)
|
||||
{
|
||||
if (!JetStreamConfigValidator.IsValidName(config.Name))
|
||||
@@ -207,6 +234,10 @@ public sealed class StreamManager : IDisposable
|
||||
return BuildStreamInfoResponse(handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns stream info for a stream by name, or a not-found API response.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
public JetStreamApiResponse GetInfo(string name)
|
||||
{
|
||||
if (_streams.TryGetValue(name, out var stream))
|
||||
@@ -215,10 +246,23 @@ public sealed class StreamManager : IDisposable
|
||||
return JetStreamApiResponse.NotFound($"$JS.API.STREAM.INFO.{name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to resolve a stream handle by name.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
/// <param name="handle">Resolved stream handle when found.</param>
|
||||
public bool TryGet(string name, out StreamHandle handle) => _streams.TryGetValue(name, out handle!);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a stream with the given name exists.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
public bool Exists(string name) => _streams.ContainsKey(name);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a stream and unregisters replication state for it.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
public bool Delete(string name)
|
||||
{
|
||||
if (!_streams.TryRemove(name, out _))
|
||||
@@ -235,6 +279,10 @@ public sealed class StreamManager : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purges all messages from a stream when purge is allowed by stream configuration.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
public bool Purge(string name)
|
||||
{
|
||||
if (!_streams.TryGetValue(name, out var stream))
|
||||
@@ -251,6 +299,10 @@ public sealed class StreamManager : IDisposable
|
||||
/// Returns the number of messages purged, or -1 if the stream was not found.
|
||||
/// Go reference: jetstream_api.go:1200-1350 — purge options: filter, seq, keep.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
/// <param name="filter">Optional subject filter used to scope which messages are purged.</param>
|
||||
/// <param name="seq">Optional exclusive upper sequence bound for purge candidates.</param>
|
||||
/// <param name="keep">Optional count of newest messages to keep.</param>
|
||||
public long PurgeEx(string name, string? filter, ulong? seq, ulong? keep)
|
||||
{
|
||||
if (!_streams.TryGetValue(name, out var stream))
|
||||
@@ -337,6 +389,11 @@ public sealed class StreamManager : IDisposable
|
||||
return purged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a stored message by stream and sequence.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
/// <param name="sequence">Message sequence to load.</param>
|
||||
public StoredMessage? GetMessage(string name, ulong sequence)
|
||||
{
|
||||
if (!_streams.TryGetValue(name, out var stream))
|
||||
@@ -345,6 +402,11 @@ public sealed class StreamManager : IDisposable
|
||||
return stream.Store.LoadAsync(sequence, default).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific message from a stream when deletes are allowed.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
/// <param name="sequence">Sequence of the message to remove.</param>
|
||||
public bool DeleteMessage(string name, ulong sequence)
|
||||
{
|
||||
if (!_streams.TryGetValue(name, out var stream))
|
||||
@@ -355,6 +417,10 @@ public sealed class StreamManager : IDisposable
|
||||
return stream.Store.RemoveAsync(sequence, default).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a binary snapshot of stream contents and metadata.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
public byte[]? CreateSnapshot(string name)
|
||||
{
|
||||
if (!_streams.TryGetValue(name, out var stream))
|
||||
@@ -363,6 +429,11 @@ public sealed class StreamManager : IDisposable
|
||||
return _snapshotService.SnapshotAsync(stream, default).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores stream state from a snapshot payload.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
/// <param name="snapshot">Snapshot payload created by <see cref="CreateSnapshot"/>.</param>
|
||||
public bool RestoreSnapshot(string name, ReadOnlyMemory<byte> snapshot)
|
||||
{
|
||||
if (!_streams.TryGetValue(name, out var stream))
|
||||
@@ -372,6 +443,11 @@ public sealed class StreamManager : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current stream state counters for a stream.
|
||||
/// </summary>
|
||||
/// <param name="name">Stream name.</param>
|
||||
/// <param name="ct">Cancellation token for store operations.</param>
|
||||
public ValueTask<Models.ApiStreamState> GetStateAsync(string name, CancellationToken ct)
|
||||
{
|
||||
if (_streams.TryGetValue(name, out var stream))
|
||||
@@ -380,6 +456,10 @@ public sealed class StreamManager : IDisposable
|
||||
return ValueTask.FromResult(new Models.ApiStreamState());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first stream whose configured subjects match the given publish subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Publish subject to match.</param>
|
||||
public StreamHandle? FindBySubject(string subject)
|
||||
{
|
||||
foreach (var stream in _streams.Values)
|
||||
@@ -391,6 +471,11 @@ public sealed class StreamManager : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures a publish into the matching stream for the provided subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Publish subject.</param>
|
||||
/// <param name="payload">Message payload.</param>
|
||||
public PubAck? Capture(string subject, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
var stream = FindBySubject(subject);
|
||||
@@ -400,6 +485,12 @@ public sealed class StreamManager : IDisposable
|
||||
return Capture(stream, subject, payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures a publish into a specific stream handle.
|
||||
/// </summary>
|
||||
/// <param name="stream">Target stream handle.</param>
|
||||
/// <param name="subject">Publish subject.</param>
|
||||
/// <param name="payload">Message payload.</param>
|
||||
public PubAck? Capture(StreamHandle stream, string subject, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
// Go: sealed stream rejects all publishes.
|
||||
@@ -489,6 +580,8 @@ public sealed class StreamManager : IDisposable
|
||||
/// The server loads the last stored value for the subject, adds the increment,
|
||||
/// and stores the new total as a JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="subject">Counter subject to increment.</param>
|
||||
/// <param name="increment">Signed increment value to add to the current counter total.</param>
|
||||
public PubAck? CaptureCounter(string subject, long increment)
|
||||
{
|
||||
var stream = FindBySubject(subject);
|
||||
@@ -534,6 +627,11 @@ public sealed class StreamManager : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests stream-leader stepdown for a replicated stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream name.</param>
|
||||
/// <param name="ct">Cancellation token for the stepdown proposal.</param>
|
||||
public Task StepDownStreamLeaderAsync(string stream, CancellationToken ct)
|
||||
{
|
||||
if (_replicaGroups.TryGetValue(stream, out var replicaGroup))
|
||||
@@ -664,6 +762,9 @@ public sealed class StreamManager : IDisposable
|
||||
/// The <paramref name="otherStreams"/> parameter is used to detect subject overlap with peer streams.
|
||||
/// Go reference: server/stream.go:1500-1600 (stream.update immutable-field checks).
|
||||
/// </summary>
|
||||
/// <param name="existing">Current persisted stream configuration.</param>
|
||||
/// <param name="proposed">Requested updated stream configuration.</param>
|
||||
/// <param name="otherStreams">Optional peer streams used for subject-overlap validation.</param>
|
||||
public static IReadOnlyList<string> ValidateConfigUpdate(
|
||||
StreamConfig existing,
|
||||
StreamConfig proposed,
|
||||
@@ -903,6 +1004,10 @@ public sealed class StreamManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active storage backend type for a stream.
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream name.</param>
|
||||
public string GetStoreBackendType(string streamName)
|
||||
{
|
||||
if (!_streams.TryGetValue(streamName, out var stream))
|
||||
@@ -920,6 +1025,7 @@ public sealed class StreamManager : IDisposable
|
||||
/// or is not configured as a mirror.
|
||||
/// Go reference: server/stream.go:2739-2743 (mirrorInfo)
|
||||
/// </summary>
|
||||
/// <param name="streamName">Mirror stream name.</param>
|
||||
public MirrorInfoResponse? GetMirrorInfo(string streamName)
|
||||
{
|
||||
if (!_streams.TryGetValue(streamName, out var stream))
|
||||
@@ -940,6 +1046,7 @@ public sealed class StreamManager : IDisposable
|
||||
/// Returns an empty array when the stream does not exist or has no sources.
|
||||
/// Go reference: server/stream.go:2687-2695 (sourcesInfo)
|
||||
/// </summary>
|
||||
/// <param name="streamName">Stream name.</param>
|
||||
public SourceInfoResponse[] GetSourceInfos(string streamName)
|
||||
{
|
||||
if (!_streams.TryGetValue(streamName, out _))
|
||||
@@ -993,6 +1100,7 @@ public sealed record StreamHandle(StreamConfig Config, IStreamStore Store)
|
||||
/// <summary>
|
||||
/// Waits until a new message is published to this stream.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token used to stop waiting.</param>
|
||||
public Task WaitForPublishAsync(CancellationToken ct)
|
||||
=> _publishSignal.Task.WaitAsync(ct);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured leaf-listen endpoint in <c>host:port</c> format.
|
||||
/// </summary>
|
||||
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
/// <summary>
|
||||
@@ -93,6 +96,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// disabled.
|
||||
/// Go reference: leafnode.go isLeafConnectDisabled.
|
||||
/// </summary>
|
||||
/// <param name="remoteUrl">Remote leaf URL to evaluate.</param>
|
||||
public bool IsLeafConnectDisabled(string remoteUrl)
|
||||
=> IsGloballyDisabled || _disabledRemotes.ContainsKey(remoteUrl);
|
||||
|
||||
@@ -100,6 +104,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns true when the remote URL is still configured and not disabled.
|
||||
/// Go reference: leafnode.go remoteLeafNodeStillValid.
|
||||
/// </summary>
|
||||
/// <param name="remoteUrl">Remote leaf URL to validate.</param>
|
||||
internal bool RemoteLeafNodeStillValid(string remoteUrl)
|
||||
{
|
||||
if (IsLeafConnectDisabled(remoteUrl))
|
||||
@@ -122,6 +127,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Has no effect if the remote is already disabled.
|
||||
/// Go reference: leafnode.go isLeafConnectDisabled — per-remote disable tracking.
|
||||
/// </summary>
|
||||
/// <param name="remoteUrl">Remote leaf URL to disable.</param>
|
||||
/// <param name="reason">Optional operator reason for diagnostics.</param>
|
||||
public void DisableLeafConnect(string remoteUrl, string? reason = null)
|
||||
{
|
||||
_disabledRemotes.TryAdd(remoteUrl, true);
|
||||
@@ -134,6 +141,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Re-enables outbound leaf connections to the specified remote URL.
|
||||
/// Has no effect if the remote was not disabled.
|
||||
/// </summary>
|
||||
/// <param name="remoteUrl">Remote leaf URL to re-enable.</param>
|
||||
public void EnableLeafConnect(string remoteUrl)
|
||||
{
|
||||
_disabledRemotes.TryRemove(remoteUrl, out _);
|
||||
@@ -145,6 +153,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Per-remote disable state is preserved.
|
||||
/// Go reference: leafnode.go isLeafConnectDisabled — global flag.
|
||||
/// </summary>
|
||||
/// <param name="reason">Optional operator reason for diagnostics.</param>
|
||||
public void DisableAllLeafConnections(string? reason = null)
|
||||
{
|
||||
IsGloballyDisabled = true;
|
||||
@@ -181,6 +190,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// no state is mutated.
|
||||
/// Go reference: leafnode.go — reloadTLSConfig hot-reload path.
|
||||
/// </summary>
|
||||
/// <param name="newCertPath">Updated certificate path to apply.</param>
|
||||
/// <param name="newKeyPath">Updated private-key path to apply.</param>
|
||||
public LeafTlsReloadResult UpdateTlsConfig(string? newCertPath, string? newKeyPath)
|
||||
{
|
||||
var previousCert = CurrentCertPath;
|
||||
@@ -202,6 +213,15 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
return new LeafTlsReloadResult(Changed: true, PreviousCertPath: previousCert, NewCertPath: newCertPath, Error: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the leaf-node manager that owns inbound/outbound leaf links.
|
||||
/// </summary>
|
||||
/// <param name="options">Leaf node options including listen endpoint and remotes.</param>
|
||||
/// <param name="stats">Shared server stats counters for leaf metrics.</param>
|
||||
/// <param name="serverId">Local server identifier used during handshake.</param>
|
||||
/// <param name="remoteSubSink">Callback for remote subscription updates.</param>
|
||||
/// <param name="messageSink">Callback for inbound leaf messages.</param>
|
||||
/// <param name="logger">Logger for lifecycle and diagnostics.</param>
|
||||
public LeafNodeManager(
|
||||
LeafNodeOptions options,
|
||||
ServerStats stats,
|
||||
@@ -224,6 +244,10 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
options.ImportSubjects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the inbound accept loop and outbound solicited reconnect loops.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token used to stop loops.</param>
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -259,6 +283,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// it is propagated during the handshake.
|
||||
/// Go reference: leafnode.go — connectSolicited.
|
||||
/// </summary>
|
||||
/// <param name="url">Remote leaf URL to connect to.</param>
|
||||
/// <param name="account">Optional account context for logging.</param>
|
||||
/// <param name="ct">Cancellation token for connect/handshake operations.</param>
|
||||
public async Task<LeafConnection> ConnectSolicitedAsync(string url, string? account, CancellationToken ct)
|
||||
{
|
||||
var endPoint = ParseEndpoint(url);
|
||||
@@ -284,6 +311,14 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a message to all active leaf connections after outbound filtering.
|
||||
/// </summary>
|
||||
/// <param name="account">Account context for the message.</param>
|
||||
/// <param name="subject">Published subject.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="payload">Payload bytes.</param>
|
||||
/// <param name="ct">Cancellation token for outbound sends.</param>
|
||||
public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
// Apply subject filtering: outbound direction is hub→leaf (DenyExports).
|
||||
@@ -301,9 +336,22 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
await connection.SendMessageAsync(account, subject, replyTo, payload, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription to leaf peers with default queue weight.
|
||||
/// </summary>
|
||||
/// <param name="account">Account owning the subscription.</param>
|
||||
/// <param name="subject">Subscribed subject pattern.</param>
|
||||
/// <param name="queue">Optional queue group name.</param>
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||
=> PropagateLocalSubscription(account, subject, queue, queueWeight: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription to leaf peers with explicit queue weight.
|
||||
/// </summary>
|
||||
/// <param name="account">Account owning the subscription.</param>
|
||||
/// <param name="subject">Subscribed subject pattern.</param>
|
||||
/// <param name="queue">Optional queue group name.</param>
|
||||
/// <param name="queueWeight">Queue weight to propagate for balancing hints.</param>
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue, int queueWeight)
|
||||
{
|
||||
// Subscription propagation is also subject to export filtering:
|
||||
@@ -329,6 +377,12 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local unsubscription to all active leaf peers.
|
||||
/// </summary>
|
||||
/// <param name="account">Account owning the unsubscription.</param>
|
||||
/// <param name="subject">Unsubscribed subject pattern.</param>
|
||||
/// <param name="queue">Optional queue group name.</param>
|
||||
public void PropagateLocalUnsubscription(string account, string subject, string? queue)
|
||||
{
|
||||
foreach (var connection in _connections.Values)
|
||||
@@ -342,6 +396,10 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// post-sync state.
|
||||
/// Go reference: leafnode.go — sendPermsAndAccountInfo.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">Connection identifier to update.</param>
|
||||
/// <param name="account">Account name to assign to the connection.</param>
|
||||
/// <param name="pubAllow">Publish allow-list subjects.</param>
|
||||
/// <param name="subAllow">Subscribe allow-list subjects.</param>
|
||||
public LeafPermSyncResult SendPermsAndAccountInfo(
|
||||
string connectionId,
|
||||
string? account,
|
||||
@@ -375,6 +433,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// which subjects have local interest.
|
||||
/// Go reference: leafnode.go — initLeafNodeSmapAndSendSubs.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">Connection identifier to seed.</param>
|
||||
/// <param name="subjects">Subjects to seed into the remote map.</param>
|
||||
public int InitLeafNodeSmapAndSendSubs(string connectionId, IEnumerable<string> subjects)
|
||||
{
|
||||
if (!_connections.TryGetValue(connectionId, out var connection))
|
||||
@@ -397,6 +457,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns the current permission-sync status for the specified connection.
|
||||
/// Go reference: leafnode.go — sendPermsAndAccountInfo (read path).
|
||||
/// </summary>
|
||||
/// <param name="connectionId">Connection identifier to query.</param>
|
||||
public LeafPermSyncResult GetPermSyncStatus(string connectionId)
|
||||
{
|
||||
if (!_connections.TryGetValue(connectionId, out var connection))
|
||||
@@ -417,6 +478,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// If another connection already uses the proposed domain, a conflict is reported.
|
||||
/// Go reference: leafnode.go checkJetStreamMigrate.
|
||||
/// </summary>
|
||||
/// <param name="connectionId">Connection identifier requesting migration.</param>
|
||||
/// <param name="proposedDomain">Proposed target domain, or null/empty to clear.</param>
|
||||
public JetStreamMigrationResult CheckJetStreamMigrate(string connectionId, string? proposedDomain)
|
||||
{
|
||||
if (!_connections.TryGetValue(connectionId, out var connection))
|
||||
@@ -463,6 +526,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns true if any currently active connection is associated with the specified JetStream domain.
|
||||
/// Go reference: leafnode.go — checkJetStreamMigrate domain-in-use check.
|
||||
/// </summary>
|
||||
/// <param name="domain">JetStream domain to search for.</param>
|
||||
public bool IsJetStreamDomainInUse(string domain)
|
||||
{
|
||||
foreach (var conn in _connections.Values)
|
||||
@@ -496,6 +560,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns false if a cluster with the same name is already registered.
|
||||
/// Go reference: leafnode.go registerLeafNodeCluster.
|
||||
/// </summary>
|
||||
/// <param name="clusterName">Cluster name key.</param>
|
||||
/// <param name="gatewayUrl">Gateway URL for the cluster.</param>
|
||||
/// <param name="connectionCount">Current connection count for the cluster.</param>
|
||||
public bool RegisterLeafNodeCluster(string clusterName, string gatewayUrl, int connectionCount)
|
||||
{
|
||||
var info = new LeafClusterInfo
|
||||
@@ -512,6 +579,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns false if no entry with that name exists.
|
||||
/// Go reference: leafnode.go — leaf cluster topology removal.
|
||||
/// </summary>
|
||||
/// <param name="clusterName">Cluster name to remove.</param>
|
||||
public bool UnregisterLeafNodeCluster(string clusterName) =>
|
||||
_leafClusters.TryRemove(clusterName, out _);
|
||||
|
||||
@@ -519,6 +587,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns true if a leaf cluster with the given name is currently registered.
|
||||
/// Go reference: leafnode.go — leaf cluster topology lookup.
|
||||
/// </summary>
|
||||
/// <param name="clusterName">Cluster name to query.</param>
|
||||
public bool HasLeafNodeCluster(string clusterName) =>
|
||||
_leafClusters.ContainsKey(clusterName);
|
||||
|
||||
@@ -526,6 +595,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns the <see cref="LeafClusterInfo"/> for the named cluster, or null if not registered.
|
||||
/// Go reference: leafnode.go — leaf cluster topology lookup.
|
||||
/// </summary>
|
||||
/// <param name="clusterName">Cluster name to query.</param>
|
||||
public LeafClusterInfo? GetLeafNodeCluster(string clusterName) =>
|
||||
_leafClusters.TryGetValue(clusterName, out var info) ? info : null;
|
||||
|
||||
@@ -547,6 +617,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// No-op if the cluster is not registered.
|
||||
/// Go reference: leafnode.go — leaf cluster connection count update.
|
||||
/// </summary>
|
||||
/// <param name="clusterName">Cluster name to update.</param>
|
||||
/// <param name="newCount">New connection count value.</param>
|
||||
public void UpdateLeafClusterConnectionCount(string clusterName, int newCount)
|
||||
{
|
||||
if (_leafClusters.TryGetValue(clusterName, out var info))
|
||||
@@ -562,12 +634,16 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Injects a <see cref="LeafConnection"/> directly into the tracked connections.
|
||||
/// For testing only — bypasses the normal handshake and registration path.
|
||||
/// </summary>
|
||||
/// <param name="connection">Connection to inject.</param>
|
||||
internal void InjectConnectionForTesting(LeafConnection connection)
|
||||
{
|
||||
var key = $"{connection.RemoteId}:{connection.RemoteEndpoint}:{Guid.NewGuid():N}";
|
||||
_connections.TryAdd(key, connection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops accept/reconnect loops and disposes all tracked leaf connections.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts == null)
|
||||
@@ -591,6 +667,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Computes the next backoff delay using exponential backoff with a cap.
|
||||
/// Delay sequence: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, ...
|
||||
/// </summary>
|
||||
/// <param name="attempt">Zero-based retry attempt count.</param>
|
||||
internal static TimeSpan ComputeBackoff(int attempt)
|
||||
{
|
||||
if (attempt < 0) attempt = 0;
|
||||
@@ -765,6 +842,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Checks for self-connect, duplicate connections, and JetStream domain conflicts.
|
||||
/// Go reference: leafnode.go addLeafNodeConnection — duplicate and domain checks.
|
||||
/// </summary>
|
||||
/// <param name="remoteId">Remote server identifier presented by the leaf peer.</param>
|
||||
/// <param name="account">Optional account requested by the remote leaf.</param>
|
||||
/// <param name="jsDomain">Optional JetStream domain advertised by the remote leaf.</param>
|
||||
public LeafValidationResult ValidateRemoteLeafNode(string remoteId, string? account, string? jsDomain)
|
||||
{
|
||||
if (IsSelfConnect(remoteId))
|
||||
@@ -791,16 +871,19 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
/// Returns true if the given remoteId matches this server's own ID (self-connect detection).
|
||||
/// Go reference: leafnode.go loop detection via server ID comparison.
|
||||
/// </summary>
|
||||
/// <param name="remoteId">Remote server identifier to compare.</param>
|
||||
public bool IsSelfConnect(string remoteId) => string.Equals(remoteId, _serverId, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any currently registered connection has the specified remote server ID.
|
||||
/// </summary>
|
||||
/// <param name="remoteId">Remote server identifier to look up.</param>
|
||||
public bool HasConnection(string remoteId) => GetConnectionByRemoteId(remoteId) != null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first registered connection whose RemoteId matches the given value, or null if none.
|
||||
/// </summary>
|
||||
/// <param name="remoteId">Remote server identifier to resolve.</param>
|
||||
public LeafConnection? GetConnectionByRemoteId(string remoteId)
|
||||
{
|
||||
foreach (var conn in _connections.Values)
|
||||
@@ -942,8 +1025,23 @@ public enum JetStreamMigrationStatus
|
||||
/// </summary>
|
||||
public sealed class LeafClusterInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the logical cluster name.
|
||||
/// </summary>
|
||||
public required string ClusterName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the gateway URL associated with this cluster.
|
||||
/// </summary>
|
||||
public required string GatewayUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the active connection count for this cluster.
|
||||
/// </summary>
|
||||
public int ConnectionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this cluster entry was registered.
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -5,33 +5,148 @@ namespace NATS.Server.Monitoring;
|
||||
/// </summary>
|
||||
public sealed record ClosedClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-assigned client identifier.
|
||||
/// </summary>
|
||||
public required ulong Cid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote client IP address.
|
||||
/// </summary>
|
||||
public string Ip { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Remote client port.
|
||||
/// </summary>
|
||||
public int Port { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connection start timestamp in UTC.
|
||||
/// </summary>
|
||||
public DateTime Start { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connection close timestamp in UTC.
|
||||
/// </summary>
|
||||
public DateTime Stop { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Close reason text.
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Client-reported name from CONNECT options.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Client-reported language from CONNECT options.
|
||||
/// </summary>
|
||||
public string Lang { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Client-reported library version from CONNECT options.
|
||||
/// </summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Authorized user identity.
|
||||
/// </summary>
|
||||
public string AuthorizedUser { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Account name used by the client.
|
||||
/// </summary>
|
||||
public string Account { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Number of inbound messages received from the client.
|
||||
/// </summary>
|
||||
public long InMsgs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of outbound messages sent to the client.
|
||||
/// </summary>
|
||||
public long OutMsgs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of inbound bytes received from the client.
|
||||
/// </summary>
|
||||
public long InBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of outbound bytes sent to the client.
|
||||
/// </summary>
|
||||
public long OutBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of active subscriptions at close time.
|
||||
/// </summary>
|
||||
public uint NumSubs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last observed round-trip time.
|
||||
/// </summary>
|
||||
public TimeSpan Rtt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Negotiated TLS protocol version.
|
||||
/// </summary>
|
||||
public string TlsVersion { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Negotiated TLS cipher suite.
|
||||
/// </summary>
|
||||
public string TlsCipherSuite { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Peer certificate subject.
|
||||
/// </summary>
|
||||
public string TlsPeerCertSubject { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of peer certificate public key subject.
|
||||
/// </summary>
|
||||
public string TlsPeerCertSubjectPkSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of peer certificate.
|
||||
/// </summary>
|
||||
public string TlsPeerCertSha256 { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// MQTT client identifier when the connection used MQTT.
|
||||
/// </summary>
|
||||
public string MqttClient { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Number of slow-consumer stalls observed.
|
||||
/// </summary>
|
||||
public long Stalls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User JWT (if present) associated with the connection.
|
||||
/// </summary>
|
||||
public string Jwt { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Issuer key for JWT-authenticated clients.
|
||||
/// </summary>
|
||||
public string IssuerKey { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name tag associated with the client.
|
||||
/// </summary>
|
||||
public string NameTag { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary tags associated with the client.
|
||||
/// </summary>
|
||||
public string[] Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Proxy key when the client was connected through a proxy.
|
||||
/// </summary>
|
||||
public string ProxyKey { get; init; } = "";
|
||||
}
|
||||
|
||||
@@ -5,9 +5,17 @@ namespace NATS.Server.Mqtt;
|
||||
|
||||
public static class MqttPacketWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes a UTF-8 string as an MQTT length-prefixed string field.
|
||||
/// </summary>
|
||||
/// <param name="value">String value to encode.</param>
|
||||
public static byte[] WriteString(string value)
|
||||
=> WriteBytes(Encoding.UTF8.GetBytes(value));
|
||||
|
||||
/// <summary>
|
||||
/// Encodes raw bytes as an MQTT length-prefixed field.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Bytes to encode.</param>
|
||||
public static byte[] WriteBytes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length > ushort.MaxValue)
|
||||
@@ -19,6 +27,12 @@ public static class MqttPacketWriter
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a complete MQTT control packet from type, payload, and flags.
|
||||
/// </summary>
|
||||
/// <param name="type">MQTT control packet type.</param>
|
||||
/// <param name="payload">Variable header and payload bytes.</param>
|
||||
/// <param name="flags">Low-nibble fixed-header flags.</param>
|
||||
public static byte[] Write(MqttControlPacketType type, ReadOnlySpan<byte> payload, byte flags = 0)
|
||||
{
|
||||
if (type == MqttControlPacketType.Reserved)
|
||||
@@ -47,6 +61,7 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes a PUBACK packet (QoS 1 acknowledgment).
|
||||
/// </summary>
|
||||
/// <param name="packetId">Packet identifier being acknowledged.</param>
|
||||
public static byte[] WritePubAck(ushort packetId)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[2];
|
||||
@@ -57,6 +72,8 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes a SUBACK packet with granted QoS values per subscription filter.
|
||||
/// </summary>
|
||||
/// <param name="packetId">SUBSCRIBE packet identifier.</param>
|
||||
/// <param name="grantedQoS">Return codes for each requested subscription.</param>
|
||||
public static byte[] WriteSubAck(ushort packetId, ReadOnlySpan<byte> grantedQoS)
|
||||
{
|
||||
var payload = new byte[2 + grantedQoS.Length];
|
||||
@@ -68,6 +85,7 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes an UNSUBACK packet.
|
||||
/// </summary>
|
||||
/// <param name="packetId">UNSUBSCRIBE packet identifier.</param>
|
||||
public static byte[] WriteUnsubAck(ushort packetId)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[2];
|
||||
@@ -84,6 +102,7 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes a PUBREC packet (QoS 2 step 1 response).
|
||||
/// </summary>
|
||||
/// <param name="packetId">PUBLISH packet identifier.</param>
|
||||
public static byte[] WritePubRec(ushort packetId)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[2];
|
||||
@@ -94,6 +113,7 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes a PUBREL packet (QoS 2 step 2). Fixed-header flags must be 0x02 per MQTT spec.
|
||||
/// </summary>
|
||||
/// <param name="packetId">PUBLISH packet identifier.</param>
|
||||
public static byte[] WritePubRel(ushort packetId)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[2];
|
||||
@@ -104,6 +124,7 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes a PUBCOMP packet (QoS 2 step 3 response).
|
||||
/// </summary>
|
||||
/// <param name="packetId">PUBLISH packet identifier.</param>
|
||||
public static byte[] WritePubComp(ushort packetId)
|
||||
{
|
||||
Span<byte> payload = stackalloc byte[2];
|
||||
@@ -114,6 +135,12 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Writes an MQTT PUBLISH packet for delivery to a client.
|
||||
/// </summary>
|
||||
/// <param name="topic">Topic name.</param>
|
||||
/// <param name="payload">Application payload bytes.</param>
|
||||
/// <param name="qos">QoS level (0, 1, or 2).</param>
|
||||
/// <param name="retain">Whether to set the retain flag.</param>
|
||||
/// <param name="dup">Whether to set the duplicate delivery flag.</param>
|
||||
/// <param name="packetId">Packet identifier used for QoS greater than zero.</param>
|
||||
public static byte[] WritePublish(string topic, ReadOnlySpan<byte> payload, byte qos = 0,
|
||||
bool retain = false, bool dup = false, ushort packetId = 0)
|
||||
{
|
||||
@@ -150,6 +177,13 @@ public static class MqttPacketWriter
|
||||
/// Writes a complete MQTT PUBLISH packet directly into a destination span.
|
||||
/// Returns the number of bytes written. Zero-allocation hot path for message delivery.
|
||||
/// </summary>
|
||||
/// <param name="dest">Destination span that receives the encoded packet.</param>
|
||||
/// <param name="topicUtf8">UTF-8 encoded topic bytes.</param>
|
||||
/// <param name="payload">Application payload bytes.</param>
|
||||
/// <param name="qos">QoS level (0, 1, or 2).</param>
|
||||
/// <param name="retain">Whether to set the retain flag.</param>
|
||||
/// <param name="dup">Whether to set the duplicate delivery flag.</param>
|
||||
/// <param name="packetId">Packet identifier used for QoS greater than zero.</param>
|
||||
public static int WritePublishTo(Span<byte> dest, ReadOnlySpan<byte> topicUtf8,
|
||||
ReadOnlySpan<byte> payload, byte qos = 0, bool retain = false, bool dup = false, ushort packetId = 0)
|
||||
{
|
||||
@@ -198,6 +232,9 @@ public static class MqttPacketWriter
|
||||
/// <summary>
|
||||
/// Calculates the total wire size of a PUBLISH packet without writing it.
|
||||
/// </summary>
|
||||
/// <param name="topicLen">Topic byte length.</param>
|
||||
/// <param name="payloadLen">Payload byte length.</param>
|
||||
/// <param name="qos">QoS level (0, 1, or 2).</param>
|
||||
public static int MeasurePublish(int topicLen, int payloadLen, byte qos)
|
||||
{
|
||||
var remainingLength = 2 + topicLen + (qos > 0 ? 2 : 0) + payloadLen;
|
||||
@@ -205,6 +242,11 @@ public static class MqttPacketWriter
|
||||
return 1 + rlLen + remainingLength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes MQTT Remaining Length into a destination span and returns encoded byte count.
|
||||
/// </summary>
|
||||
/// <param name="dest">Destination span for encoded bytes.</param>
|
||||
/// <param name="value">Remaining length value to encode.</param>
|
||||
internal static int EncodeRemainingLengthTo(Span<byte> dest, int value)
|
||||
{
|
||||
var index = 0;
|
||||
@@ -220,6 +262,10 @@ public static class MqttPacketWriter
|
||||
return index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of bytes required to encode MQTT Remaining Length.
|
||||
/// </summary>
|
||||
/// <param name="value">Remaining length value.</param>
|
||||
internal static int MeasureRemainingLength(int value)
|
||||
{
|
||||
var count = 0;
|
||||
@@ -232,6 +278,10 @@ public static class MqttPacketWriter
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes MQTT Remaining Length into a new byte array.
|
||||
/// </summary>
|
||||
/// <param name="value">Remaining length value.</param>
|
||||
internal static byte[] EncodeRemainingLength(int value)
|
||||
{
|
||||
if (value < 0 || value > MqttProtocolConstants.MaxPayloadSize)
|
||||
|
||||
@@ -20,15 +20,41 @@ namespace NATS.Server;
|
||||
|
||||
public interface IMessageRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Routes a published message through account matching, queue selection, and remote forwarding.
|
||||
/// </summary>
|
||||
/// <param name="subject">Published subject.</param>
|
||||
/// <param name="replyTo">Optional reply subject for request-reply.</param>
|
||||
/// <param name="headers">Optional NATS header block bytes.</param>
|
||||
/// <param name="payload">Published payload bytes.</param>
|
||||
/// <param name="sender">Client that originated the publish.</param>
|
||||
void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
|
||||
ReadOnlyMemory<byte> payload, INatsClient sender);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client from server-wide tracking and subscription indexes.
|
||||
/// </summary>
|
||||
/// <param name="client">Client connection to remove.</param>
|
||||
void RemoveClient(INatsClient client);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a connect advisory for a successfully authenticated client.
|
||||
/// </summary>
|
||||
/// <param name="client">Client that connected.</param>
|
||||
void PublishConnectEvent(INatsClient client);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a disconnect advisory for a closed client connection.
|
||||
/// </summary>
|
||||
/// <param name="client">Client that disconnected.</param>
|
||||
void PublishDisconnectEvent(INatsClient client);
|
||||
}
|
||||
|
||||
public interface ISubListAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the server's global subscription index.
|
||||
/// </summary>
|
||||
SubList SubList { get; }
|
||||
}
|
||||
|
||||
@@ -41,12 +67,20 @@ internal readonly struct OutboundData
|
||||
public readonly ReadOnlyMemory<byte> Data;
|
||||
public readonly byte[]? PoolBuffer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates outbound payload metadata with an optional pooled backing buffer handle.
|
||||
/// </summary>
|
||||
/// <param name="data">Data slice to write to the client.</param>
|
||||
/// <param name="poolBuffer">Optional pooled buffer to return after write completion.</param>
|
||||
public OutboundData(ReadOnlyMemory<byte> data, byte[]? poolBuffer = null)
|
||||
{
|
||||
Data = data;
|
||||
PoolBuffer = poolBuffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the byte length of the outbound payload.
|
||||
/// </summary>
|
||||
public int Length => Data.Length;
|
||||
}
|
||||
|
||||
@@ -90,12 +124,39 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
private ClientPermissions? _permissions;
|
||||
private readonly ServerStats _serverStats;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server-assigned client identifier.
|
||||
/// </summary>
|
||||
public ulong Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection kind (client/router/gateway/leaf/system).
|
||||
/// </summary>
|
||||
public ClientKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets parsed CONNECT options sent by the client.
|
||||
/// </summary>
|
||||
public ClientOptions? ClientOpts { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracing metadata propagated from CONNECT options.
|
||||
/// </summary>
|
||||
public MessageTraceContext TraceContext { get; private set; } = MessageTraceContext.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the router responsible for server-side publish/disconnect handling.
|
||||
/// </summary>
|
||||
public IMessageRouter? Router { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authenticated account assigned to this client.
|
||||
/// </summary>
|
||||
public Account? Account { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the permission evaluator resolved during authentication.
|
||||
/// </summary>
|
||||
public ClientPermissions? Permissions => _permissions;
|
||||
|
||||
/// <summary>
|
||||
@@ -105,9 +166,20 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
public string? MqttClientId { get; set; }
|
||||
|
||||
private readonly ClientFlagHolder _flags = new();
|
||||
/// <summary>
|
||||
/// Gets whether a valid CONNECT command has been processed.
|
||||
/// </summary>
|
||||
public bool ConnectReceived => _flags.HasFlag(ClientFlags.ConnectReceived);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first close reason recorded for this connection.
|
||||
/// </summary>
|
||||
public ClientClosedReason CloseReason { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables or disables protocol trace logging for this connection.
|
||||
/// </summary>
|
||||
/// <param name="enabled">`true` to enable trace logging; `false` to disable.</param>
|
||||
public void SetTraceMode(bool enabled)
|
||||
{
|
||||
if (enabled)
|
||||
@@ -122,10 +194,24 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this client connection was created.
|
||||
/// </summary>
|
||||
public DateTime StartTime { get; }
|
||||
private long _lastActivityTicks;
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last observed client activity.
|
||||
/// </summary>
|
||||
public DateTime LastActivity => new(Interlocked.Read(ref _lastActivityTicks), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remote client IP address, when available.
|
||||
/// </summary>
|
||||
public string? RemoteIp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remote client TCP port, when available.
|
||||
/// </summary>
|
||||
public int RemotePort { get; }
|
||||
|
||||
// Stats
|
||||
@@ -140,6 +226,9 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
|
||||
// Close reason tracking
|
||||
private int _skipFlushOnClose;
|
||||
/// <summary>
|
||||
/// Gets whether close handling should skip flush due to fatal I/O conditions.
|
||||
/// </summary>
|
||||
public bool ShouldSkipFlush => Volatile.Read(ref _skipFlushOnClose) != 0;
|
||||
|
||||
// PING keepalive state
|
||||
@@ -149,18 +238,59 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
// RTT tracking
|
||||
private long _rttStartTicks;
|
||||
private long _rtt;
|
||||
/// <summary>
|
||||
/// Gets the most recent round-trip time measured from PING/PONG.
|
||||
/// </summary>
|
||||
public TimeSpan Rtt => new(Interlocked.Read(ref _rtt));
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this connection proxies an MQTT client.
|
||||
/// </summary>
|
||||
public bool IsMqtt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this connection is a WebSocket transport.
|
||||
/// </summary>
|
||||
public bool IsWebSocket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets captured WebSocket upgrade metadata.
|
||||
/// </summary>
|
||||
public WsUpgradeResult? WsInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets TLS session state captured for this connection.
|
||||
/// </summary>
|
||||
public TlsConnectionState? TlsState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether INFO has already been transmitted for this connection.
|
||||
/// </summary>
|
||||
public bool InfoAlreadySent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active subscriptions owned by this client, keyed by SID.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last JetStream publish acknowledgement observed for this client.
|
||||
/// </summary>
|
||||
public PubAck? LastJetStreamPubAck { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a per-connection NATS client runtime for protocol parsing and I/O.
|
||||
/// </summary>
|
||||
/// <param name="id">Server-assigned client identifier.</param>
|
||||
/// <param name="stream">Transport stream used for reads/writes.</param>
|
||||
/// <param name="socket">Underlying socket for shutdown and optimized send paths.</param>
|
||||
/// <param name="options">Server options that affect protocol limits and timeouts.</param>
|
||||
/// <param name="serverInfo">Server INFO metadata visible to this client.</param>
|
||||
/// <param name="authService">Authentication service used for CONNECT validation.</param>
|
||||
/// <param name="nonce">Optional nonce for NKey/JWT auth handshake.</param>
|
||||
/// <param name="logger">Logger for connection diagnostics.</param>
|
||||
/// <param name="serverStats">Shared server stats sink for aggregated counters.</param>
|
||||
/// <param name="kind">Connection kind classification.</param>
|
||||
public NatsClient(ulong id, Stream stream, Socket socket, NatsOptions options, ServerInfo serverInfo,
|
||||
AuthService authService, byte[]? nonce, ILogger logger, ServerStats serverStats,
|
||||
ClientKind kind = ClientKind.Client)
|
||||
@@ -186,10 +316,19 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the auth nonce bytes for this client, when nonce auth is enabled.
|
||||
/// </summary>
|
||||
public byte[]? GetNonce() => _nonce?.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the client-provided name from CONNECT options.
|
||||
/// </summary>
|
||||
public string GetName() => ClientOpts?.Name ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the external connection type used by monitoring endpoints.
|
||||
/// </summary>
|
||||
public ClientConnectionType ClientType()
|
||||
{
|
||||
if (Kind != ClientKind.Client)
|
||||
@@ -201,12 +340,19 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
return ClientConnectionType.Nats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a compact connection identity string for diagnostics.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
var endpoint = RemoteIp is null ? "unknown" : $"{RemoteIp}:{RemotePort}";
|
||||
return $"{Kind} cid={Id} endpoint={endpoint}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues raw protocol bytes for outbound delivery to the client.
|
||||
/// </summary>
|
||||
/// <param name="data">Encoded protocol bytes to queue.</param>
|
||||
public bool QueueOutbound(ReadOnlyMemory<byte> data) => QueueOutboundCore(new OutboundData(data));
|
||||
|
||||
/// <summary>
|
||||
@@ -256,6 +402,9 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets queued outbound bytes awaiting write-loop flush.
|
||||
/// </summary>
|
||||
public long PendingBytes => Interlocked.Read(ref _pendingBytes);
|
||||
|
||||
/// <summary>
|
||||
@@ -301,6 +450,10 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// </summary>
|
||||
public bool ShouldCoalesceFlush => FlushSignalsPending < MaxFlushPending;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the client read/parse/write lifecycle until disconnect or cancellation.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token used to stop client processing.</param>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_clientCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -768,6 +921,10 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
Router?.ProcessMessage(subject, replyTo, headers, payload, this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the most recent JetStream publish acknowledgement for monitoring/debugging.
|
||||
/// </summary>
|
||||
/// <param name="ack">Publish acknowledgement returned by JetStream capture.</param>
|
||||
public void RecordJetStreamPubAck(PubAck ack)
|
||||
{
|
||||
LastJetStreamPubAck = ack;
|
||||
@@ -791,6 +948,14 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats and queues a `MSG`/`HMSG` delivery, then signals the write loop.
|
||||
/// </summary>
|
||||
/// <param name="subject">Delivered subject.</param>
|
||||
/// <param name="sid">Subscription SID receiving the message.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="headers">Optional header bytes for HMSG deliveries.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
public void SendMessage(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
@@ -803,6 +968,11 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// Callers must call <see cref="SignalFlush"/> after all messages in a batch are queued.
|
||||
/// Go reference: client.go addToPCD — deferred flush via pcd map.
|
||||
/// </summary>
|
||||
/// <param name="subject">Delivered subject.</param>
|
||||
/// <param name="sid">Subscription SID receiving the message.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="headers">Optional header bytes for HMSG deliveries.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
public void SendMessageNoFlush(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
@@ -932,6 +1102,11 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// Fast-path overload accepting pre-encoded subject and SID bytes to avoid
|
||||
/// per-delivery ASCII encoding in fan-out scenarios.
|
||||
/// </summary>
|
||||
/// <param name="subjectBytes">Pre-encoded subject bytes.</param>
|
||||
/// <param name="sidBytes">Pre-encoded SID bytes.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="headers">Optional header bytes for HMSG deliveries.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
public void SendMessageNoFlush(ReadOnlySpan<byte> subjectBytes, ReadOnlySpan<byte> sidBytes, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
@@ -992,6 +1167,11 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// (" [reply] sizes\r\n") once per publish. Only the SID varies per delivery.
|
||||
/// Eliminates per-delivery replyTo encoding, size formatting, and prefix/subject copying.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Preformatted message prefix up to (and including) SID separator.</param>
|
||||
/// <param name="sidBytes">Pre-encoded SID bytes for the destination subscription.</param>
|
||||
/// <param name="suffix">Preformatted suffix containing reply/size tokens and CRLF.</param>
|
||||
/// <param name="headers">Optional header bytes for HMSG deliveries.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
public void SendMessagePreformatted(ReadOnlySpan<byte> prefix, ReadOnlySpan<byte> sidBytes,
|
||||
ReadOnlySpan<byte> suffix, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
@@ -1072,6 +1252,10 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
QueueOutbound(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a protocol error line to the client.
|
||||
/// </summary>
|
||||
/// <param name="message">Error text inserted into the `-ERR` line.</param>
|
||||
public void SendErr(string message)
|
||||
{
|
||||
var errLine = Encoding.ASCII.GetBytes($"-ERR '{message}'\r\n");
|
||||
@@ -1227,6 +1411,11 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an error and then closes the connection with the provided close reason.
|
||||
/// </summary>
|
||||
/// <param name="message">Error text inserted into the `-ERR` line.</param>
|
||||
/// <param name="reason">Close reason recorded for this client.</param>
|
||||
public async Task SendErrAndCloseAsync(string message, ClientClosedReason reason = ClientClosedReason.ProtocolViolation)
|
||||
{
|
||||
await CloseWithReasonAsync(reason, message);
|
||||
@@ -1303,6 +1492,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// Sets skip-flush flag for error-related reasons.
|
||||
/// Only the first call sets the reason (subsequent calls are no-ops).
|
||||
/// </summary>
|
||||
/// <param name="reason">Close reason to record.</param>
|
||||
public void MarkClosed(ClientClosedReason reason)
|
||||
{
|
||||
if (CloseReason != ClientClosedReason.None)
|
||||
@@ -1327,6 +1517,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// <summary>
|
||||
/// Flushes pending data (unless skip-flush is set) and closes the connection.
|
||||
/// </summary>
|
||||
/// <param name="minimalFlush">Whether to use a shorter best-effort flush window before close.</param>
|
||||
public async Task FlushAndCloseAsync(bool minimalFlush = false)
|
||||
{
|
||||
if (!ShouldSkipFlush)
|
||||
@@ -1349,12 +1540,20 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a single subscription by SID and decrements account subscription counters.
|
||||
/// </summary>
|
||||
/// <param name="sid">Subscription SID to remove.</param>
|
||||
public void RemoveSubscription(string sid)
|
||||
{
|
||||
if (_subs.Remove(sid))
|
||||
Account?.DecrementSubscriptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all client subscriptions from the provided subscription index.
|
||||
/// </summary>
|
||||
/// <param name="subList">Subscription list to remove this client's subscriptions from.</param>
|
||||
public void RemoveAllSubscriptions(SubList subList)
|
||||
{
|
||||
foreach (var sub in _subs.Values)
|
||||
@@ -1362,6 +1561,9 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes connection resources and completes outbound channels.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_permissions?.Dispose();
|
||||
@@ -1390,6 +1592,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// Go reference: server/client.go — routes/gateways/leafnodes get TcpFlush,
|
||||
/// regular clients get Close.
|
||||
/// </summary>
|
||||
/// <param name="kind">Connection kind to evaluate.</param>
|
||||
public static WriteTimeoutPolicy GetWriteTimeoutPolicy(ClientKind kind) => kind switch
|
||||
{
|
||||
ClientKind.Client => WriteTimeoutPolicy.Close,
|
||||
@@ -1429,6 +1632,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// The stall threshold is set at 75% of maxPending.
|
||||
/// Go reference: server/client.go stc channel creation.
|
||||
/// </summary>
|
||||
/// <param name="maxPending">Maximum pending bytes configured for the client.</param>
|
||||
public StallGate(long maxPending)
|
||||
{
|
||||
_threshold = maxPending * 3 / 4;
|
||||
@@ -1444,6 +1648,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// Updates pending byte count and activates/deactivates the stall gate.
|
||||
/// Go reference: server/client.go stalledRoute check.
|
||||
/// </summary>
|
||||
/// <param name="pending">Current pending outbound byte count.</param>
|
||||
public void UpdatePending(long pending)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -1464,6 +1669,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
/// false if timed out (indicating the client should be closed as slow consumer).
|
||||
/// Go reference: server/client.go stc channel receive with timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum duration to wait for stall release.</param>
|
||||
public async Task<bool> WaitAsync(TimeSpan timeout)
|
||||
{
|
||||
SemaphoreSlim? sem;
|
||||
|
||||
@@ -69,6 +69,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal RouteManager? RouteManager => _routeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the gateway manager for gateway topology and interest propagation tests.
|
||||
/// </summary>
|
||||
internal GatewayManager? GatewayManager => _gatewayManager;
|
||||
private readonly GatewayManager? _gatewayManager;
|
||||
private readonly LeafNodeManager? _leafNodeManager;
|
||||
@@ -109,13 +113,44 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the global-account subscription index used for default subject routing.
|
||||
/// </summary>
|
||||
public SubList SubList => _globalAccount.SubList;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached `INFO` protocol line broadcast to connected clients.
|
||||
/// </summary>
|
||||
public byte[] CachedInfoLine => _cachedInfoLine;
|
||||
|
||||
/// <summary>
|
||||
/// Gets runtime counters for client, route, and message activity.
|
||||
/// </summary>
|
||||
public ServerStats Stats => _stats;
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this server instance started accepting client traffic.
|
||||
/// </summary>
|
||||
public DateTime StartTime => new(Interlocked.Read(ref _startTimeTicks), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique server identifier advertised to peers and clients.
|
||||
/// </summary>
|
||||
public string ServerId => _serverInfo.ServerId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable server name used in monitoring and advisories.
|
||||
/// </summary>
|
||||
public string ServerName => _serverInfo.ServerName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current number of tracked client connections.
|
||||
/// </summary>
|
||||
public int ClientCount => _clients.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TCP client listen port for the NATS protocol endpoint.
|
||||
/// </summary>
|
||||
public int Port => _options.Port;
|
||||
|
||||
/// <summary>
|
||||
@@ -130,25 +165,95 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
public IEnumerable<Mqtt.MqttNatsClientAdapter> GetMqttAdapters()
|
||||
=> _mqttListener?.GetMqttAdapters() ?? [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the system account used for `$SYS` advisories and internal control traffic.
|
||||
/// </summary>
|
||||
public Account SystemAccount => _systemAccount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets this server's public NKey identity.
|
||||
/// </summary>
|
||||
public string ServerNKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal event system used to publish server advisories.
|
||||
/// </summary>
|
||||
public InternalEventSystem? EventSystem => _eventSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether shutdown has started and connection lifecycle is draining.
|
||||
/// </summary>
|
||||
public bool IsShuttingDown => Volatile.Read(ref _shutdown) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the server is in lame-duck mode and no longer accepting new clients.
|
||||
/// </summary>
|
||||
public bool IsLameDuckMode => Volatile.Read(ref _lameDuck) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active cluster route listen endpoint, if routing is enabled.
|
||||
/// </summary>
|
||||
public string? ClusterListen => _routeManager?.ListenEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active gateway listen endpoint, if gateways are enabled.
|
||||
/// </summary>
|
||||
public string? GatewayListen => _gatewayManager?.ListenEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active leaf node listen endpoint, if leaf links are enabled.
|
||||
/// </summary>
|
||||
public string? LeafListen => _leafNodeManager?.ListenEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether profiling is configured for this server process.
|
||||
/// </summary>
|
||||
public bool IsProfilingEnabled => _options.ProfPort > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal JetStream control client used for system-side operations.
|
||||
/// </summary>
|
||||
public InternalClient? JetStreamInternalClient => _jetStreamInternalClient;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JetStream API router used for `$JS.API.*` request handling.
|
||||
/// </summary>
|
||||
public JetStreamApiRouter? JetStreamApiRouter => _jetStreamApiRouter;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of configured JetStream streams currently known by the server.
|
||||
/// </summary>
|
||||
public int JetStreamStreams => _jetStreamStreamManager?.StreamNames.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active JetStream consumers currently tracked.
|
||||
/// </summary>
|
||||
public int JetStreamConsumers => _jetStreamConsumerManager?.ConsumerCount ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a callback used when `SIGUSR1` requests log file reopening.
|
||||
/// </summary>
|
||||
public Action? ReOpenLogFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns all currently tracked client connections.
|
||||
/// </summary>
|
||||
public IEnumerable<NatsClient> GetClients() => _clients.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured cluster name when clustering is enabled.
|
||||
/// </summary>
|
||||
public string? ClusterName() => _options.Cluster?.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Returns connected peer server IDs from the current route topology snapshot.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ActivePeers()
|
||||
=> _routeManager?.BuildTopologySnapshot().ConnectedServerIds ?? [];
|
||||
|
||||
/// <summary>
|
||||
/// Starts profiler exposure when configured; currently reports unsupported status.
|
||||
/// </summary>
|
||||
public bool StartProfiler()
|
||||
{
|
||||
if (_options.ProfPort <= 0)
|
||||
@@ -158,12 +263,23 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects a client by ID using minimal flush semantics.
|
||||
/// </summary>
|
||||
/// <param name="clientId">Server-assigned client identifier to disconnect.</param>
|
||||
public bool DisconnectClientByID(ulong clientId)
|
||||
=> CloseClientById(clientId, minimalFlush: true);
|
||||
|
||||
/// <summary>
|
||||
/// Initiates lame-duck client closure semantics for a specific client ID.
|
||||
/// </summary>
|
||||
/// <param name="clientId">Server-assigned client identifier to close.</param>
|
||||
public bool LDMClientByID(ulong clientId)
|
||||
=> CloseClientById(clientId, minimalFlush: false);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the server endpoints payload used by startup tooling and orchestration.
|
||||
/// </summary>
|
||||
public Ports PortsInfo()
|
||||
{
|
||||
var ports = new Ports();
|
||||
@@ -189,6 +305,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
return ports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns advertised client connect URLs used by cluster peers and clients.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetConnectURLs()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise))
|
||||
@@ -202,6 +321,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the advertised INFO payload and pushes it to connected clients.
|
||||
/// </summary>
|
||||
public void UpdateServerINFOAndSendINFOToClients()
|
||||
{
|
||||
_serverInfo.ConnectUrls = [.. GetConnectURLs()];
|
||||
@@ -214,6 +336,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the primary client URL for local status surfaces and tooling.
|
||||
/// </summary>
|
||||
public string ClientURL()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise))
|
||||
@@ -223,6 +348,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
return $"nats://{host}:{_options.Port}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the primary WebSocket URL when WebSocket transport is enabled.
|
||||
/// </summary>
|
||||
public string? WebsocketURL()
|
||||
{
|
||||
if (_options.WebSocket.Port < 0)
|
||||
@@ -239,18 +367,45 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
return $"{wsScheme}://{wsHost}:{_options.WebSocket.Port}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active route connection count.
|
||||
/// </summary>
|
||||
public int NumRoutes() => (int)Interlocked.Read(ref _stats.Routes);
|
||||
|
||||
/// <summary>
|
||||
/// Returns total remote links across routes, gateways, and leaf nodes.
|
||||
/// </summary>
|
||||
public int NumRemotes()
|
||||
=> (int)(Interlocked.Read(ref _stats.Routes) + Interlocked.Read(ref _stats.Gateways) + Interlocked.Read(ref _stats.Leafs));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active leaf-node connection count.
|
||||
/// </summary>
|
||||
public int NumLeafNodes() => (int)Interlocked.Read(ref _stats.Leafs);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of outbound gateway connections.
|
||||
/// </summary>
|
||||
public int NumOutboundGateways() => _gatewayManager?.NumOutboundGateways() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of inbound gateway connections.
|
||||
/// </summary>
|
||||
public int NumInboundGateways() => _gatewayManager?.NumInboundGateways() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of subscriptions across loaded accounts.
|
||||
/// </summary>
|
||||
public int NumSubscriptions() => _accounts.Values.Sum(acc => acc.SubscriptionCount);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether JetStream services are initialized and running.
|
||||
/// </summary>
|
||||
public bool JetStreamEnabled() => _jetStreamService?.IsRunning ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of configured JetStream limits and storage settings.
|
||||
/// </summary>
|
||||
public JetStreamOptions? JetStreamConfig()
|
||||
{
|
||||
if (_options.JetStream is null)
|
||||
@@ -267,37 +422,98 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the JetStream storage directory path, or an empty value when disabled.
|
||||
/// </summary>
|
||||
public string StoreDir() => _options.JetStream?.StoreDir ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Returns when the active configuration snapshot was last updated.
|
||||
/// </summary>
|
||||
public DateTime ConfigTime() => _configTime;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the primary client listen address as `host:port`.
|
||||
/// </summary>
|
||||
public string Addr() => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the monitoring listen address when monitoring is enabled.
|
||||
/// </summary>
|
||||
public string? MonitorAddr()
|
||||
=> _options.MonitorPort > 0
|
||||
? $"{_options.MonitorHost}:{_options.MonitorPort}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured cluster listen endpoint.
|
||||
/// </summary>
|
||||
public string? ClusterAddr() => _routeManager?.ListenEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured gateway listen endpoint.
|
||||
/// </summary>
|
||||
public string? GatewayAddr() => _gatewayManager?.ListenEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the gateway URL used for external discovery.
|
||||
/// </summary>
|
||||
public string? GetGatewayURL() => _gatewayManager?.ListenEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured gateway name for cross-cluster identity.
|
||||
/// </summary>
|
||||
public string? GetGatewayName() => _options.Gateway?.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the profiler address when profiling is enabled.
|
||||
/// </summary>
|
||||
public string? ProfilerAddr()
|
||||
=> _options.ProfPort > 0
|
||||
? $"{_options.Host}:{_options.ProfPort}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of accounts currently serving at least one client.
|
||||
/// </summary>
|
||||
public int NumActiveAccounts() => _accounts.Values.Count(acc => acc.ClientCount > 0);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of loaded accounts.
|
||||
/// </summary>
|
||||
public int NumLoadedAccounts() => _accounts.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the closed-client ring buffer snapshot for monitoring endpoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ClosedClient> GetClosedClients() => _closedClients.GetAll();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all known accounts currently loaded in this server.
|
||||
/// </summary>
|
||||
public IEnumerable<Auth.Account> GetAccounts() => _accounts.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether any remote peer has declared interest in a subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to evaluate for remote route/gateway/leaf interest.</param>
|
||||
public bool HasRemoteInterest(string subject) => _globalAccount.SubList.HasRemoteInterest(subject);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether any remote peer has declared interest for a subject in a specific account.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name used to evaluate scoped remote interest.</param>
|
||||
/// <param name="subject">Subject to evaluate for remote route/gateway/leaf interest.</param>
|
||||
public bool HasRemoteInterest(string account, string subject)
|
||||
=> GetOrCreateAccount(account).SubList.HasRemoteInterest(account, subject);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to persist a publish into JetStream and emit consumer notifications.
|
||||
/// </summary>
|
||||
/// <param name="subject">Published subject used for stream matching.</param>
|
||||
/// <param name="payload">Published message payload.</param>
|
||||
/// <param name="ack">Acknowledgement data describing capture outcome and sequence.</param>
|
||||
/// <returns><see langword="true" /> when the publish was captured by JetStream.</returns>
|
||||
public bool TryCaptureJetStreamPublish(string subject, ReadOnlyMemory<byte> payload, out PubAck ack)
|
||||
{
|
||||
if (_jetStreamPublisher != null && _jetStreamPublisher.TryCapture(subject, payload, out ack))
|
||||
@@ -402,21 +618,49 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a task that completes once core listeners are ready for traffic.
|
||||
/// </summary>
|
||||
public Task WaitForReadyAsync() => _listeningStarted.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until server shutdown has completed.
|
||||
/// </summary>
|
||||
public void WaitForShutdown() => _shutdownComplete.Task.GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the active TLS certificate provider for integration tests.
|
||||
/// </summary>
|
||||
internal TlsCertificateProvider? TlsCertProviderForTest => _tlsCertProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Acquires the config reload lock for deterministic test coordination.
|
||||
/// </summary>
|
||||
internal Task AcquireReloadLockForTestAsync() => _reloadMu.WaitAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Releases the config reload lock previously acquired by tests.
|
||||
/// </summary>
|
||||
internal void ReleaseReloadLockForTest() => _reloadMu.Release();
|
||||
|
||||
/// <summary>
|
||||
/// Installs a test hook for accept-loop transient error handling.
|
||||
/// </summary>
|
||||
/// <param name="handler">Handler invoked when accept-loop errors occur.</param>
|
||||
internal void SetAcceptLoopErrorHandlerForTest(AcceptLoopErrorHandler handler) => _acceptLoopErrorHandler = handler;
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the configured accept-loop test hook with supplied error details.
|
||||
/// </summary>
|
||||
/// <param name="ex">Exception observed by the accept loop.</param>
|
||||
/// <param name="endpoint">Endpoint involved in the failed accept operation.</param>
|
||||
/// <param name="delay">Backoff delay selected before the next accept attempt.</param>
|
||||
internal void NotifyAcceptErrorForTest(Exception ex, EndPoint? endpoint, TimeSpan delay) =>
|
||||
_acceptLoopErrorHandler?.OnAcceptError(ex, endpoint, delay);
|
||||
|
||||
/// <summary>
|
||||
/// Gracefully shuts down listeners, internal services, and active clients.
|
||||
/// </summary>
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _shutdown, 1, 0) != 0)
|
||||
@@ -500,6 +744,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_shutdownComplete.TrySetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts lame-duck mode, drains existing clients, then performs full shutdown.
|
||||
/// </summary>
|
||||
public async Task LameDuckShutdownAsync()
|
||||
{
|
||||
if (IsShuttingDown || Interlocked.CompareExchange(ref _lameDuck, 1, 0) != 0)
|
||||
@@ -624,6 +871,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the server runtime, account model, transports, and optional subsystems.
|
||||
/// </summary>
|
||||
/// <param name="options">Server options that define listeners, auth, clustering, and feature flags.</param>
|
||||
/// <param name="loggerFactory">Logger factory used to create component loggers.</param>
|
||||
public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_options = options;
|
||||
@@ -790,6 +1042,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private static bool IsWildcardHost(string host)
|
||||
=> host == "0.0.0.0" || host == "::";
|
||||
|
||||
/// <summary>
|
||||
/// Expands wildcard listen hosts into non-loopback interface addresses for client advertise URLs.
|
||||
/// </summary>
|
||||
/// <param name="host">Configured listen host value, including wildcard forms like `0.0.0.0` or `::`.</param>
|
||||
/// <returns>Resolved IP addresses that clients can use to connect back to this server.</returns>
|
||||
internal static IReadOnlyList<string> GetNonLocalIPsIfHostIsIPAny(string host)
|
||||
{
|
||||
if (!IsWildcardHost(host))
|
||||
@@ -852,6 +1109,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
targets.Add(endpoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts listeners and optional subsystems, then begins accepting client traffic.
|
||||
/// </summary>
|
||||
/// <param name="ct">External cancellation token used to stop startup and accept loops.</param>
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _quitCts.Token);
|
||||
@@ -1252,6 +1513,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription addition to route, gateway, and leaf-node peers.
|
||||
/// </summary>
|
||||
/// <param name="account">Account that owns the subscription.</param>
|
||||
/// <param name="subject">Subscribed subject pattern.</param>
|
||||
/// <param name="queue">Optional queue group name for queue subscriptions.</param>
|
||||
public void OnLocalSubscription(string account, string subject, string? queue)
|
||||
{
|
||||
_routeManager?.PropagateLocalSubscription(account, subject, queue);
|
||||
@@ -1259,6 +1526,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_leafNodeManager?.PropagateLocalSubscription(account, subject, queue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription removal to route, gateway, and leaf-node peers.
|
||||
/// </summary>
|
||||
/// <param name="account">Account that owns the removed subscription.</param>
|
||||
/// <param name="subject">Subject pattern being unsubscribed.</param>
|
||||
/// <param name="queue">Optional queue group name for queue subscriptions.</param>
|
||||
public void OnLocalUnsubscription(string account, string subject, string? queue)
|
||||
{
|
||||
_routeManager?.PropagateLocalUnsubscription(account, subject, queue);
|
||||
@@ -1345,6 +1618,14 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an incoming publish, including JetStream capture, import/export mapping, and fan-out delivery.
|
||||
/// </summary>
|
||||
/// <param name="subject">Published subject used for subscription and stream matching.</param>
|
||||
/// <param name="replyTo">Optional reply subject for request-reply semantics.</param>
|
||||
/// <param name="headers">Optional NATS header block bytes for HMSG publications.</param>
|
||||
/// <param name="payload">Published message payload bytes.</param>
|
||||
/// <param name="sender">Originating client connection used for account and permission context.</param>
|
||||
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
|
||||
ReadOnlyMemory<byte> payload, INatsClient sender)
|
||||
{
|
||||
@@ -1998,6 +2279,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// subscribers in the destination account.
|
||||
/// Reference: Go server/accounts.go addServiceImport / processServiceImport.
|
||||
/// </summary>
|
||||
/// <param name="si">Service import definition that describes source and destination account mapping.</param>
|
||||
/// <param name="subject">Incoming subject from the importing account.</param>
|
||||
/// <param name="replyTo">Optional reply subject to wire reverse import routing.</param>
|
||||
/// <param name="headers">Optional header bytes to forward with the imported message.</param>
|
||||
/// <param name="payload">Message payload bytes to deliver to destination subscribers.</param>
|
||||
/// <param name="sourceAccount">Source account that published the imported message.</param>
|
||||
public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, Account? sourceAccount = null)
|
||||
{
|
||||
@@ -2168,6 +2455,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// it checks the account's Imports.Services.
|
||||
/// Reference: Go server/accounts.go addServiceImportSub.
|
||||
/// </summary>
|
||||
/// <param name="account">Importer account whose configured service imports are being wired.</param>
|
||||
public void WireServiceImports(Account account)
|
||||
{
|
||||
foreach (var kvp in account.Imports.Services)
|
||||
@@ -2217,6 +2505,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
sender.QueueOutbound(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an existing account or creates one from static configuration on first use.
|
||||
/// </summary>
|
||||
/// <param name="name">Account name to resolve.</param>
|
||||
public Account GetOrCreateAccount(string name)
|
||||
{
|
||||
return _accounts.GetOrAdd(name, n =>
|
||||
@@ -2279,6 +2571,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Returns true if the subject belongs to the $SYS subject space.
|
||||
/// Reference: Go server/server.go — isReservedSubject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject string to evaluate.</param>
|
||||
public static bool IsSystemSubject(string subject)
|
||||
=> subject.StartsWith("$SYS.", StringComparison.Ordinal) || subject == "$SYS";
|
||||
|
||||
@@ -2287,6 +2580,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Non-system accounts cannot subscribe to $SYS.> subjects.
|
||||
/// Reference: Go server/accounts.go — isReservedForSys.
|
||||
/// </summary>
|
||||
/// <param name="account">Account requesting the subscription.</param>
|
||||
/// <param name="subject">Subject pattern being subscribed.</param>
|
||||
public bool IsSubscriptionAllowed(Account? account, string subject)
|
||||
{
|
||||
if (!IsSystemSubject(subject))
|
||||
@@ -2304,6 +2599,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// for $SYS.> subjects, or the provided account's SubList for everything else.
|
||||
/// Reference: Go server/server.go — sublist routing for internal subjects.
|
||||
/// </summary>
|
||||
/// <param name="account">Account context for non-system subject routing.</param>
|
||||
/// <param name="subject">Subject used to determine whether system routing is required.</param>
|
||||
public SubList GetSubListForSubject(Account? account, string subject)
|
||||
{
|
||||
if (IsSystemSubject(subject))
|
||||
@@ -2331,11 +2628,23 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
SendInternalMsg(latency.Subject, reply: null, msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an internal system message through the event bus.
|
||||
/// </summary>
|
||||
/// <param name="subject">Destination subject for the internal publish.</param>
|
||||
/// <param name="reply">Optional reply subject for request-response workflows.</param>
|
||||
/// <param name="msg">Payload object to serialize for publication.</param>
|
||||
public void SendInternalMsg(string subject, string? reply, object? msg)
|
||||
{
|
||||
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an internal account-scoped advisory through the event bus.
|
||||
/// </summary>
|
||||
/// <param name="account">Account associated with the advisory context.</param>
|
||||
/// <param name="subject">Destination subject for the advisory publish.</param>
|
||||
/// <param name="msg">Payload object to serialize for publication.</param>
|
||||
public void SendInternalAccountMsg(Account account, string subject, object? msg)
|
||||
{
|
||||
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Body = msg });
|
||||
@@ -2345,6 +2654,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Handles $SYS.REQ.SERVER.{id}.VARZ requests.
|
||||
/// Returns core server information including stats counters.
|
||||
/// </summary>
|
||||
/// <param name="subject">Request subject used to target this server instance.</param>
|
||||
/// <param name="reply">Reply inbox subject where the response should be published.</param>
|
||||
public void HandleVarzRequest(string subject, string? reply)
|
||||
{
|
||||
if (reply == null) return;
|
||||
@@ -2370,6 +2681,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Handles $SYS.REQ.SERVER.{id}.HEALTHZ requests.
|
||||
/// Returns a simple health status response.
|
||||
/// </summary>
|
||||
/// <param name="subject">Request subject used to target this server instance.</param>
|
||||
/// <param name="reply">Reply inbox subject where the response should be published.</param>
|
||||
public void HandleHealthzRequest(string subject, string? reply)
|
||||
{
|
||||
if (reply == null) return;
|
||||
@@ -2380,6 +2693,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Handles $SYS.REQ.SERVER.{id}.SUBSZ requests.
|
||||
/// Returns the current subscription count.
|
||||
/// </summary>
|
||||
/// <param name="subject">Request subject used to target this server instance.</param>
|
||||
/// <param name="reply">Reply inbox subject where the response should be published.</param>
|
||||
public void HandleSubszRequest(string subject, string? reply)
|
||||
{
|
||||
if (reply == null) return;
|
||||
@@ -2390,6 +2705,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Handles $SYS.REQ.SERVER.{id}.STATSZ requests.
|
||||
/// Publishes current server statistics through the event system.
|
||||
/// </summary>
|
||||
/// <param name="subject">Request subject used to target this server instance.</param>
|
||||
/// <param name="reply">Reply inbox subject where the response should be published.</param>
|
||||
public void HandleStatszRequest(string subject, string? reply)
|
||||
{
|
||||
if (reply == null) return;
|
||||
@@ -2429,6 +2746,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Handles $SYS.REQ.SERVER.{id}.IDZ requests.
|
||||
/// Returns basic server identity information.
|
||||
/// </summary>
|
||||
/// <param name="subject">Request subject used to target this server instance.</param>
|
||||
/// <param name="reply">Reply inbox subject where the response should be published.</param>
|
||||
public void HandleIdzRequest(string subject, string? reply)
|
||||
{
|
||||
if (reply == null) return;
|
||||
@@ -2478,6 +2797,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Publishes a $SYS.ACCOUNT.{account}.CONNECT advisory when a client
|
||||
/// completes authentication. Maps to Go's sendConnectEvent in events.go.
|
||||
/// </summary>
|
||||
/// <param name="client">Client that completed CONNECT authentication.</param>
|
||||
public void PublishConnectEvent(INatsClient client)
|
||||
{
|
||||
if (_eventSystem == null || client is not NatsClient natsClient) return;
|
||||
@@ -2497,6 +2817,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Publishes a $SYS.ACCOUNT.{account}.DISCONNECT advisory when a client
|
||||
/// disconnects. Maps to Go's sendDisconnectEvent in events.go.
|
||||
/// </summary>
|
||||
/// <param name="client">Client that disconnected from this server.</param>
|
||||
public void PublishDisconnectEvent(INatsClient client)
|
||||
{
|
||||
if (_eventSystem == null || client is not NatsClient natsClient) return;
|
||||
@@ -2523,6 +2844,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
SendInternalMsg(subject, null, evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client from runtime tracking, subscriptions, and closed-client monitoring.
|
||||
/// </summary>
|
||||
/// <param name="client">Client connection to remove from server state.</param>
|
||||
public void RemoveClient(INatsClient client)
|
||||
{
|
||||
if (client is not NatsClient natsClient)
|
||||
@@ -2703,6 +3028,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// Stores the CLI snapshot and flags so that command-line overrides
|
||||
/// always take precedence during config reload.
|
||||
/// </summary>
|
||||
/// <param name="cliSnapshot">Original CLI option values captured at process startup.</param>
|
||||
/// <param name="cliFlags">CLI flags explicitly provided by the operator.</param>
|
||||
public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet<string> cliFlags)
|
||||
{
|
||||
_cliSnapshot = cliSnapshot;
|
||||
@@ -2718,6 +3045,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
ReloadConfigCore(throwOnError: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads configuration and throws when reload validation or apply steps fail.
|
||||
/// </summary>
|
||||
public void ReloadConfigOrThrow()
|
||||
{
|
||||
ReloadConfigCore(throwOnError: true);
|
||||
@@ -2953,9 +3283,15 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_options.SystemAccount = newOpts.SystemAccount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a compact server identity string for diagnostics.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
=> $"NatsServer(ServerId={ServerId}, Name={ServerName}, Addr={Addr()}, Clients={ClientCount})";
|
||||
|
||||
/// <summary>
|
||||
/// Disposes managed resources and signal registrations associated with this server.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!IsShuttingDown)
|
||||
|
||||
@@ -75,58 +75,106 @@ public static class NatsProtocol
|
||||
|
||||
public sealed class ServerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique server identifier advertised to clients and peers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("server_id")]
|
||||
public required string ServerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the human-readable server name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("server_name")]
|
||||
public required string ServerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server version string.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protocol version number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proto")]
|
||||
public int Proto { get; set; } = NatsProtocol.ProtoVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the host clients should connect to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("host")]
|
||||
public required string Host { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client port.
|
||||
/// </summary>
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether header support is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("headers")]
|
||||
public bool Headers { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum accepted payload size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("max_payload")]
|
||||
public int MaxPayload { get; set; } = NatsProtocol.MaxPayloadSize;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the assigned client identifier in per-client INFO payloads.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ulong ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the remote client IP address in per-client INFO payloads.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_ip")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ClientIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether authentication is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("auth_required")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool AuthRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets nonce text used for NKey/JWT auth challenge.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nonce")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Nonce { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether TLS is required for clients.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tls_required")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether mutual TLS verification is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tls_verify")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsVerify { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether TLS is available in mixed-mode setups.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tls_available")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets alternative connect URLs advertised to clients.
|
||||
/// </summary>
|
||||
[JsonPropertyName("connect_urls")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string[]? ConnectUrls { get; set; }
|
||||
@@ -134,48 +182,93 @@ public sealed class ServerInfo
|
||||
|
||||
public sealed class ClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether `+OK` acknowledgements are requested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verbose")]
|
||||
public bool Verbose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether strict protocol validation is requested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("pedantic")]
|
||||
public bool Pedantic { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the server should echo publishes back to this client.
|
||||
/// </summary>
|
||||
[JsonPropertyName("echo")]
|
||||
public bool Echo { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets client application name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets client implementation language.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lang")]
|
||||
public string? Lang { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets client library version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets protocol mode requested by the client.
|
||||
/// </summary>
|
||||
[JsonPropertyName("protocol")]
|
||||
public int Protocol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the client supports headers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("headers")]
|
||||
public bool Headers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether no-responder status messages are requested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("no_responders")]
|
||||
public bool NoResponders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets username credential.
|
||||
/// </summary>
|
||||
[JsonPropertyName("user")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets password credential.
|
||||
/// </summary>
|
||||
[JsonPropertyName("pass")]
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets bearer auth token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("auth_token")]
|
||||
public string? Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets NKey public key used for challenge authentication.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nkey")]
|
||||
public string? Nkey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets challenge signature for NKey auth.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sig")]
|
||||
public string? Sig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets user JWT token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("jwt")]
|
||||
public string? JWT { get; set; }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
private Socket? _listener;
|
||||
private Task? _acceptLoopTask;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured route-listen endpoint in `host:port` form.
|
||||
/// </summary>
|
||||
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
/// <summary>
|
||||
@@ -51,6 +54,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// such route connection exists.
|
||||
/// Go reference: server/route.go negotiateRoutePool.
|
||||
/// </summary>
|
||||
/// <param name="remoteServerId">Optional remote server identifier used to scope lookup.</param>
|
||||
public int GetEffectivePoolSize(string? remoteServerId)
|
||||
{
|
||||
if (remoteServerId is { Length: > 0 })
|
||||
@@ -68,6 +72,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
return ConfiguredPoolSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a snapshot of current route connectivity for monitoring and tests.
|
||||
/// </summary>
|
||||
public RouteTopologySnapshot BuildTopologySnapshot()
|
||||
{
|
||||
return new RouteTopologySnapshot(
|
||||
@@ -76,6 +83,15 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
_connectedServerIds.Keys.OrderBy(static k => k, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the route manager that owns route listeners, dials, and inter-server forwarding.
|
||||
/// </summary>
|
||||
/// <param name="options">Cluster route options including listen host/port and seed routes.</param>
|
||||
/// <param name="stats">Shared server stats counters for route metrics.</param>
|
||||
/// <param name="serverId">Local server identifier advertised to peers.</param>
|
||||
/// <param name="remoteSubSink">Callback for remote subscription updates received from routes.</param>
|
||||
/// <param name="routedMessageSink">Callback for routed publish messages received from peers.</param>
|
||||
/// <param name="logger">Logger for route lifecycle and error diagnostics.</param>
|
||||
public RouteManager(
|
||||
ClusterOptions options,
|
||||
ServerStats stats,
|
||||
@@ -106,7 +122,15 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
|
||||
/// </summary>
|
||||
public event Action<List<string>>? OnForwardInfo;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the final connection for a remote server ID is removed.
|
||||
/// </summary>
|
||||
public event Action<string>? OnRouteRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a dedicated account route is removed for a remote server/account pair.
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnRouteAccountRemoved;
|
||||
|
||||
/// <summary>
|
||||
@@ -114,6 +138,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// known are added to DiscoveredRoutes for solicited connection.
|
||||
/// Go reference: server/route.go:1500-1550 (processImplicitRoute).
|
||||
/// </summary>
|
||||
/// <param name="serverInfo">Peer server INFO payload containing discovered connect URLs.</param>
|
||||
public void ProcessImplicitRoute(ServerInfo serverInfo)
|
||||
{
|
||||
if (serverInfo.ConnectUrls is null || serverInfo.ConnectUrls.Length == 0)
|
||||
@@ -142,6 +167,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Forwards new peer URL information to all known route connections.
|
||||
/// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
|
||||
/// </summary>
|
||||
/// <param name="newPeerUrl">New peer route URL to gossip to connected peers.</param>
|
||||
public void ForwardNewRouteInfoToKnownServers(string newPeerUrl)
|
||||
{
|
||||
OnForwardInfo?.Invoke([newPeerUrl]);
|
||||
@@ -150,6 +176,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Adds a URL to the known route set. Used during initialization and testing.
|
||||
/// </summary>
|
||||
/// <param name="url">Route URL to register as known/configured.</param>
|
||||
public void AddKnownRoute(string url)
|
||||
{
|
||||
lock (_discoveredRoutes)
|
||||
@@ -163,6 +190,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// known from startup/config processing.
|
||||
/// Go reference: server/route.go hasThisRouteConfigured.
|
||||
/// </summary>
|
||||
/// <param name="routeUrl">Route URL to evaluate.</param>
|
||||
internal bool HasThisRouteConfigured(string routeUrl)
|
||||
{
|
||||
var normalized = NormalizeRouteUrl(routeUrl);
|
||||
@@ -179,6 +207,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Returns true if the route URL is still valid for reconnect attempts.
|
||||
/// Go reference: server/route.go routeStillValid.
|
||||
/// </summary>
|
||||
/// <param name="routeUrl">Route URL to validate against configured/discovered sets.</param>
|
||||
internal bool RouteStillValid(string routeUrl)
|
||||
{
|
||||
var normalized = NormalizeRouteUrl(routeUrl);
|
||||
@@ -197,6 +226,8 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// <c>computeRoutePoolIdx</c> (route.go:533-545). Uses FNV-1a 32-bit hash
|
||||
/// to deterministically map account names to pool indices.
|
||||
/// </summary>
|
||||
/// <param name="poolSize">Pool width to map into.</param>
|
||||
/// <param name="accountName">Account name used as hash input.</param>
|
||||
public static int ComputeRoutePoolIdx(int poolSize, string accountName)
|
||||
{
|
||||
if (poolSize <= 1)
|
||||
@@ -222,6 +253,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Go reference: server/route.go — per-account dedicated route lookup,
|
||||
/// getRoutesExcludePool (legacy fallback).
|
||||
/// </summary>
|
||||
/// <param name="account">Account name requiring a route connection.</param>
|
||||
public RouteConnection? GetRouteForAccount(string account)
|
||||
{
|
||||
// 1st: Check dedicated account routes (Gap 13.2).
|
||||
@@ -274,6 +306,8 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// previous connection was registered for the same account it is replaced.
|
||||
/// Go reference: server/route.go — per-account dedicated route registration.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to bind to the route connection.</param>
|
||||
/// <param name="connection">Route connection that should handle this account.</param>
|
||||
public void RegisterAccountRoute(string account, RouteConnection connection)
|
||||
{
|
||||
_accountRoutes[account] = connection;
|
||||
@@ -283,6 +317,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Removes the dedicated route for the given account. If no dedicated route
|
||||
/// was registered this is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name whose dedicated route should be removed.</param>
|
||||
public void UnregisterAccountRoute(string account)
|
||||
{
|
||||
if (!_accountRoutes.TryRemove(account, out var route))
|
||||
@@ -296,12 +331,14 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Returns the dedicated route connection for the given account, or null if
|
||||
/// no dedicated route has been registered.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to look up.</param>
|
||||
public RouteConnection? GetDedicatedAccountRoute(string account)
|
||||
=> _accountRoutes.TryGetValue(account, out var connection) ? connection : null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when a dedicated route is registered for the given account.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to check.</param>
|
||||
public bool HasDedicatedRoute(string account)
|
||||
=> _accountRoutes.ContainsKey(account);
|
||||
|
||||
@@ -328,6 +365,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// index variant but uses 64-bit constants for a wider key space.
|
||||
/// Go reference: server/route.go — route hash key derivation.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Remote server identifier to hash.</param>
|
||||
public static ulong ComputeRouteHash(string serverId)
|
||||
{
|
||||
const ulong fnvOffset = 14695981039346656037UL;
|
||||
@@ -347,6 +385,8 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// same server ID is overwritten.
|
||||
/// Go reference: server/route.go — route hash registration.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Remote server identifier used as hash key source.</param>
|
||||
/// <param name="connection">Route connection to register.</param>
|
||||
public void RegisterRouteByHash(string serverId, RouteConnection connection)
|
||||
{
|
||||
var hash = ComputeRouteHash(serverId);
|
||||
@@ -358,6 +398,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// If no entry exists this is a no-op.
|
||||
/// Go reference: server/route.go — route hash deregistration.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Remote server identifier whose hash entry should be removed.</param>
|
||||
public void UnregisterRouteByHash(string serverId)
|
||||
{
|
||||
var hash = ComputeRouteHash(serverId);
|
||||
@@ -369,6 +410,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// or <c>null</c> if no entry exists. O(1) lookup.
|
||||
/// Go reference: server/route.go — O(1) route lookup by hash.
|
||||
/// </summary>
|
||||
/// <param name="hash">Precomputed route hash key.</param>
|
||||
public RouteConnection? GetRouteByHash(ulong hash)
|
||||
=> _routesByHash.TryGetValue(hash, out var connection) ? connection : null;
|
||||
|
||||
@@ -377,6 +419,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// and returns the associated route connection, or <c>null</c>.
|
||||
/// Go reference: server/route.go — server-ID-keyed route lookup.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Remote server identifier to resolve.</param>
|
||||
public RouteConnection? GetRouteByServerId(string serverId)
|
||||
=> GetRouteByHash(ComputeRouteHash(serverId));
|
||||
|
||||
@@ -385,6 +428,10 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// </summary>
|
||||
public int HashedRouteCount => _routesByHash.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the route listener, registers manager state, and begins outbound seed dials.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token used to stop listener and dial loops.</param>
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -412,6 +459,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops route listener/dials and disposes active route connections.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts == null)
|
||||
@@ -434,6 +484,12 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription add to connected route peers (once per peer).
|
||||
/// </summary>
|
||||
/// <param name="account">Account owning the subscription.</param>
|
||||
/// <param name="subject">Subscribed subject pattern.</param>
|
||||
/// <param name="queue">Optional queue group name.</param>
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||
{
|
||||
if (_routes.IsEmpty)
|
||||
@@ -449,6 +505,12 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription remove to connected route peers (once per peer).
|
||||
/// </summary>
|
||||
/// <param name="account">Account owning the subscription.</param>
|
||||
/// <param name="subject">Unsubscribed subject pattern.</param>
|
||||
/// <param name="queue">Optional queue group name.</param>
|
||||
public void PropagateLocalUnsubscription(string account, string subject, string? queue)
|
||||
{
|
||||
if (_routes.IsEmpty)
|
||||
@@ -463,6 +525,14 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a routed publish to peer routes, using pool selection when available.
|
||||
/// </summary>
|
||||
/// <param name="account">Account context for the routed message.</param>
|
||||
/// <param name="subject">Published subject.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
/// <param name="ct">Cancellation token for outbound sends.</param>
|
||||
public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
if (_routes.IsEmpty)
|
||||
@@ -497,6 +567,11 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Used for JetStream replication where every peer must receive the message.
|
||||
/// Go reference: server/route.go — broadcastMsgToRoutes for RAFT proposals.
|
||||
/// </summary>
|
||||
/// <param name="account">Account context for the routed message.</param>
|
||||
/// <param name="subject">Published subject.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
/// <param name="ct">Cancellation token for outbound sends.</param>
|
||||
public async Task BroadcastRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
if (_routes.IsEmpty)
|
||||
@@ -612,6 +687,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an outbound route dial socket with route-specific keepalive behavior.
|
||||
/// </summary>
|
||||
internal static Socket CreateRouteDialSocket()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
@@ -693,6 +771,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
return new IPEndPoint(IPAddress.Parse(parts[0]), int.Parse(parts[1]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active route connections.
|
||||
/// </summary>
|
||||
public int RouteCount => _routes.Count;
|
||||
|
||||
/// <summary>
|
||||
@@ -700,6 +781,8 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// and for programmatic registration of routes by server ID.
|
||||
/// Go reference: server/route.go addRoute (internal registration path).
|
||||
/// </summary>
|
||||
/// <param name="serverId">Remote server identifier for the route.</param>
|
||||
/// <param name="connection">Route connection to register.</param>
|
||||
internal void RegisterRoute(string serverId, RouteConnection connection)
|
||||
{
|
||||
var key = $"{serverId}:{Guid.NewGuid():N}";
|
||||
@@ -713,6 +796,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Returns true if a route was found and removed, false otherwise.
|
||||
/// Go reference: server/route.go removeRoute (lines 3113+).
|
||||
/// </summary>
|
||||
/// <param name="serverId">Remote server identifier to remove.</param>
|
||||
public bool RemoveRoute(string serverId)
|
||||
{
|
||||
var found = false;
|
||||
@@ -758,6 +842,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Returns the number of routes removed.
|
||||
/// Go reference: server/route.go removeAllRoutesExcept (lines 3085-3111).
|
||||
/// </summary>
|
||||
/// <param name="keepServerIds">Server IDs that should remain connected.</param>
|
||||
public int RemoveAllRoutesExcept(IReadOnlySet<string> keepServerIds)
|
||||
{
|
||||
var removed = 0;
|
||||
@@ -787,6 +872,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// are absent from the connected set.
|
||||
/// Go reference: server/route.go — cluster split / network partition detection.
|
||||
/// </summary>
|
||||
/// <param name="expectedPeers">Expected full peer set for a healthy cluster.</param>
|
||||
public ClusterSplitResult DetectClusterSplit(IReadOnlySet<string> expectedPeers)
|
||||
{
|
||||
var connected = _connectedServerIds.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
@@ -804,6 +890,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
return new ClusterSplitResult(missing, unexpected, missing.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a solicited route exists for the specified remote server.
|
||||
/// </summary>
|
||||
internal bool HasSolicitedRoute(string remoteServerId)
|
||||
{
|
||||
var prefix = remoteServerId + ":";
|
||||
@@ -813,6 +902,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
|| string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the first matching route to the remote server as solicited.
|
||||
/// </summary>
|
||||
internal bool UpgradeRouteToSolicited(string remoteServerId)
|
||||
{
|
||||
var prefix = remoteServerId + ":";
|
||||
@@ -831,6 +923,9 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether the remote server ID is already present in the connected set.
|
||||
/// </summary>
|
||||
internal bool IsDuplicateServerName(string remoteServerId)
|
||||
=> _connectedServerIds.ContainsKey(remoteServerId);
|
||||
|
||||
|
||||
@@ -35,27 +35,56 @@ public sealed class SubList : IDisposable
|
||||
private readonly Dictionary<string, List<Action<bool>>> _queueRemoveNotifications = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly record struct CachedResult(SubListResult Result, long Generation);
|
||||
/// <summary>
|
||||
/// Raised when local or remote interest changes for a subject/queue tuple.
|
||||
/// </summary>
|
||||
public event Action<InterestChange>? InterestChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription list with match-result caching enabled.
|
||||
/// </summary>
|
||||
public SubList()
|
||||
: this(enableCache: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription list with optional subject-match caching.
|
||||
/// </summary>
|
||||
/// <param name="enableCache">Whether to enable cache entries for repeated subject matches.</param>
|
||||
public SubList(bool enableCache)
|
||||
{
|
||||
if (!enableCache)
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription list with caching disabled.
|
||||
/// </summary>
|
||||
public static SubList NewSublistNoCache() => new(enableCache: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether match-result caching is currently enabled.
|
||||
/// </summary>
|
||||
public bool CacheEnabled() => _cache != null;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback notified when overall interest transitions between empty/non-empty.
|
||||
/// </summary>
|
||||
/// <param name="callback">Callback invoked with <see langword="true"/> when interest appears and <see langword="false"/> when it drains.</param>
|
||||
public void RegisterNotification(Action<bool> callback) => _interestStateNotification = callback;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the overall interest transition callback.
|
||||
/// </summary>
|
||||
public void ClearNotification() => _interestStateNotification = null;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback for queue-specific interest insert/remove transitions.
|
||||
/// </summary>
|
||||
/// <param name="subject">Exact subject to observe.</param>
|
||||
/// <param name="queue">Queue group name to observe.</param>
|
||||
/// <param name="callback">Callback invoked with current interest state and future transitions.</param>
|
||||
public bool RegisterQueueNotification(string subject, string queue, Action<bool> callback)
|
||||
{
|
||||
if (callback == null || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(queue))
|
||||
@@ -82,6 +111,12 @@ public sealed class SubList : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously registered queue-specific interest callback.
|
||||
/// </summary>
|
||||
/// <param name="subject">Exact subject associated with the callback.</param>
|
||||
/// <param name="queue">Queue group associated with the callback.</param>
|
||||
/// <param name="callback">Callback delegate instance to remove.</param>
|
||||
public bool ClearQueueNotification(string subject, string queue, Action<bool> callback)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
@@ -99,12 +134,18 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the subscription list lock and prevents further operations.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_lock.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of subscriptions currently stored in the trie.
|
||||
/// </summary>
|
||||
public uint Count
|
||||
{
|
||||
get
|
||||
@@ -171,10 +212,20 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of high-fanout trie nodes currently using packed list optimization.
|
||||
/// </summary>
|
||||
internal int HighFanoutNodeCountForTest => Volatile.Read(ref _highFanoutNodes);
|
||||
|
||||
/// <summary>
|
||||
/// Triggers cache sweeping immediately for deterministic tests.
|
||||
/// </summary>
|
||||
internal Task TriggerCacheSweepAsyncForTest() => _sweeper.TriggerSweepAsync(SweepCache);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a remote subscription add/remove update into the routed-interest table.
|
||||
/// </summary>
|
||||
/// <param name="sub">Remote subscription delta from a route, gateway, or leaf connection.</param>
|
||||
public void ApplyRemoteSub(RemoteSubscription sub)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
@@ -217,6 +268,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates queue weight for an existing remote queue subscription.
|
||||
/// </summary>
|
||||
/// <param name="sub">Remote queue subscription update containing the new queue weight.</param>
|
||||
public void UpdateRemoteQSub(RemoteSubscription sub)
|
||||
{
|
||||
if (sub.Queue == null)
|
||||
@@ -242,6 +297,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all remote subscriptions associated with a route connection.
|
||||
/// </summary>
|
||||
/// <param name="routeId">Route connection identifier whose subscriptions should be removed.</param>
|
||||
public int RemoveRemoteSubs(string routeId)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
@@ -283,6 +342,11 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes remote subscriptions for a specific route/account pair.
|
||||
/// </summary>
|
||||
/// <param name="routeId">Route connection identifier.</param>
|
||||
/// <param name="account">Account name scoped to the remote subscriptions to remove.</param>
|
||||
public int RemoveRemoteSubsForAccount(string routeId, string account)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
@@ -327,9 +391,18 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether any remote subscription matches the provided global-account subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to test for remote interest.</param>
|
||||
public bool HasRemoteInterest(string subject)
|
||||
=> HasRemoteInterest("$G", subject);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether any remote subscription in the account matches the subject.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to test.</param>
|
||||
/// <param name="subject">Subject to test for remote interest.</param>
|
||||
public bool HasRemoteInterest(string account, string subject)
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
@@ -354,6 +427,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a local subscription into the subject trie and invalidates match generation.
|
||||
/// </summary>
|
||||
/// <param name="sub">Subscription to insert.</param>
|
||||
public void Insert(Subscription sub)
|
||||
{
|
||||
var subject = sub.Subject;
|
||||
@@ -435,6 +512,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a local subscription from the trie and updates interest state.
|
||||
/// </summary>
|
||||
/// <param name="sub">Subscription to remove.</param>
|
||||
public void Remove(Subscription sub)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -552,6 +633,10 @@ public sealed class SubList : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a subject against local subscriptions and returns plain/queue results.
|
||||
/// </summary>
|
||||
/// <param name="subject">Concrete publish subject.</param>
|
||||
public SubListResult Match(string subject)
|
||||
{
|
||||
_matches++;
|
||||
@@ -604,11 +689,20 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a UTF-8 subject span by first decoding it to a string.
|
||||
/// </summary>
|
||||
/// <param name="subjectUtf8">UTF-8 encoded subject bytes.</param>
|
||||
public SubListResult MatchBytes(ReadOnlySpan<byte> subjectUtf8)
|
||||
{
|
||||
return Match(Encoding.ASCII.GetString(subjectUtf8));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns expanded remote matches for account/subject, accounting for queue weights.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to match remote interest within.</param>
|
||||
/// <param name="subject">Subject to match.</param>
|
||||
public IReadOnlyList<RemoteSubscription> MatchRemote(string account, string subject)
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
@@ -923,6 +1017,9 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregate counters and cache fan-out statistics for monitoring.
|
||||
/// </summary>
|
||||
public SubListStats Stats()
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
@@ -984,6 +1081,10 @@ public sealed class SubList : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether any local subscription has interest in the subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to test for local interest.</param>
|
||||
public bool HasInterest(string subject)
|
||||
{
|
||||
var currentGen = Interlocked.Read(ref _generation);
|
||||
@@ -1015,6 +1116,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns counts of plain and queue subscription interest for a subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to inspect.</param>
|
||||
public (int plainCount, int queueCount) NumInterest(string subject)
|
||||
{
|
||||
var tokens = Tokenize(subject);
|
||||
@@ -1033,6 +1138,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes multiple subscriptions under one write lock and single generation bump.
|
||||
/// </summary>
|
||||
/// <param name="subs">Subscriptions to remove.</param>
|
||||
public void RemoveBatch(IEnumerable<Subscription> subs)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
@@ -1062,6 +1171,9 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all subscriptions (local trie entries) for introspection.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Subscription> All()
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
@@ -1077,6 +1189,10 @@ public sealed class SubList : IDisposable
|
||||
return subs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns local subscriptions, optionally including leaf-hub placeholders.
|
||||
/// </summary>
|
||||
/// <param name="includeLeafHubs">Whether to include leaf-hub placeholder subscriptions.</param>
|
||||
public IReadOnlyList<Subscription> LocalSubs(bool includeLeafHubs = false)
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
@@ -1092,6 +1208,9 @@ public sealed class SubList : IDisposable
|
||||
return subs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns trie depth for diagnostic/test visibility.
|
||||
/// </summary>
|
||||
internal int NumLevels()
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
@@ -1105,6 +1224,10 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a concrete subject against subscription patterns in reverse direction.
|
||||
/// </summary>
|
||||
/// <param name="subject">Concrete subject to reverse-match against wildcard subscriptions.</param>
|
||||
public SubListResult ReverseMatch(string subject)
|
||||
{
|
||||
var tokens = Tokenize(subject);
|
||||
@@ -1391,16 +1514,29 @@ public sealed class SubList : IDisposable
|
||||
{
|
||||
private ReadOnlySpan<char> _remaining;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a tokenizer over a dot-separated subject string.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject text to tokenize.</param>
|
||||
public TokenEnumerator(string subject)
|
||||
{
|
||||
_remaining = subject.AsSpan();
|
||||
Current = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current token slice.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<char> Current { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the enumerator instance for `foreach` support.
|
||||
/// </summary>
|
||||
public TokenEnumerator GetEnumerator() => this;
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next token.
|
||||
/// </summary>
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_remaining.IsEmpty)
|
||||
@@ -1435,6 +1571,9 @@ public sealed class SubList : IDisposable
|
||||
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
|
||||
public bool PackedListEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this trie node has no local subscriptions and no child branches.
|
||||
/// </summary>
|
||||
public bool IsEmpty => PlainSubs.Count == 0 && QueueSubs.Count == 0 &&
|
||||
(Next == null || (Next.Nodes.Count == 0 && Next.Pwc == null && Next.Fwc == null));
|
||||
}
|
||||
@@ -1445,8 +1584,14 @@ public sealed class SubList : IDisposable
|
||||
private readonly List<List<Subscription>> _queueGroups = [];
|
||||
private int _queueGroupCount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accumulated plain subscriptions for the current match operation.
|
||||
/// </summary>
|
||||
public List<Subscription> PlainSubs { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Clears all accumulated match state for reuse.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
PlainSubs.Clear();
|
||||
@@ -1456,6 +1601,11 @@ public sealed class SubList : IDisposable
|
||||
_queueGroupCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a queue group's subscriptions into the current match aggregation.
|
||||
/// </summary>
|
||||
/// <param name="queueName">Queue group key.</param>
|
||||
/// <param name="subs">Subscriptions to add for the queue group.</param>
|
||||
public void AddQueueGroup(string queueName, HashSet<Subscription> subs)
|
||||
{
|
||||
if (!_queueIndexes.TryGetValue(queueName, out var index))
|
||||
@@ -1469,6 +1619,9 @@ public sealed class SubList : IDisposable
|
||||
_queueGroups[index].AddRange(subs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes the builder state into an immutable match result.
|
||||
/// </summary>
|
||||
public SubListResult ToResult()
|
||||
{
|
||||
if (PlainSubs.Count == 0 && _queueGroupCount == 0)
|
||||
|
||||
Reference in New Issue
Block a user