Improve XML documentation coverage across core server components and refresh checker reports.

This commit is contained in:
Joseph Doherty
2026-03-14 02:26:53 -04:00
parent 007baf3fa4
commit 56c773dc71
34 changed files with 5109 additions and 21694 deletions
+214
View File
@@ -0,0 +1,214 @@
# dotTrace Command-Line Profiler
## Installation
Installed as a .NET global tool:
```bash
dotnet tool install --global JetBrains.dotTrace.GlobalTools
```
Update to latest:
```bash
dotnet tool update --global JetBrains.dotTrace.GlobalTools
```
Current version: **2025.3.3**
## Quick Start
### Profile the NATS server (sampling, 30 seconds)
```bash
dottrace start --framework=NetCore --profiling-type=Sampling \
--timeout=30s --save-to=./snapshots/nats-sampling.dtp \
-- dotnet run --project src/NATS.Server.Host -- -p 14222
```
### Profile the NATS server (timeline, with async/TPL info)
```bash
dottrace start --framework=NetCore --profiling-type=Timeline \
--timeout=30s --save-to=./snapshots/nats-timeline.dtt \
-- dotnet run --project src/NATS.Server.Host -- -p 14222
```
### Attach to a running server by PID
```bash
dottrace attach <PID> --profiling-type=Sampling \
--timeout=30s --save-to=./snapshots/nats-attach.dtp
```
### Attach by process name
```bash
dottrace attach NATS.Server.Host --profiling-type=Sampling \
--timeout=30s --save-to=./snapshots/nats-attach.dtp
```
## Profiling Types
| Type | Flag | Snapshot Extension | Use Case |
|------|------|--------------------|----------|
| Sampling | `--profiling-type=Sampling` | `.dtp` | Low overhead, CPU hotspots (default) |
| Timeline | `--profiling-type=Timeline` | `.dtt` | Thread activity, async/await, TPL tasks |
| Tracing | `--profiling-type=Tracing` | `.dtp` | Exact call counts, higher overhead |
| Line-by-Line | `--profiling-type=LineByLine` | `.dtp` | Per-line timing (not available for attach) |
### Sampling options
```bash
# Use thread time instead of CPU instructions
--time-measurement=ThreadTime
# Default (CPU instruction count)
--time-measurement=CpuInstruction
```
### Timeline options
```bash
# Disable TPL data collection for better performance
--disable-tpl
```
## Common Options
| Option | Description |
|--------|-------------|
| `--framework=NetCore` | Required for .NET Core / .NET 5+ apps |
| `--save-to=<path>` | Snapshot output path (file or directory) |
| `--overwrite` | Overwrite existing snapshot files |
| `--timeout=<duration>` | Auto-stop after duration (e.g., `30s`, `5m`, `1h`) |
| `--propagate-exit-code` | Return the profiled app's exit code instead of dotTrace's |
| `--profile-child` | Also profile child processes |
| `--profile-child=<mask>` | Profile matching child processes (e.g., `dotnet`) |
| `--work-dir=<path>` | Set working directory for the profiled app |
| `--collect-data-from-start=off` | Don't collect until explicitly started via service messages |
## Interactive Profiling with Service Messages
For fine-grained control over when data is collected, use `--service-input=stdin`:
```bash
dottrace start --framework=NetCore --service-input=stdin \
--save-to=./snapshots/nats-interactive.dtp \
-- dotnet run --project src/NATS.Server.Host -- -p 14222
```
Then type these commands into stdin (each must start on a new line and end with a carriage return):
| Command | Effect |
|---------|--------|
| `##dotTrace["start"]` | Start collecting performance data |
| `##dotTrace["get-snapshot"]` | Save snapshot and stop collecting |
| `##dotTrace["drop"]` | Discard collected data and stop |
| `##dotTrace["disconnect"]` | Detach/stop profiler |
Stdout will emit status messages like:
```
##dotTrace["ready"]
##dotTrace["connected", {pid: 1234, path:"dotnet"}]
##dotTrace["started", {pid: 1234, path:"dotnet"}]
##dotTrace["snapshot-saved", {pid: 1234, filename:"./snapshots/nats-interactive.dtp"}]
```
## Example Workflows
### Profile a benchmark run
```bash
dottrace start --framework=NetCore --profiling-type=Sampling \
--save-to=./snapshots/bench.dtp \
-- dotnet run --project tests/NATS.Server.Benchmarks -c Release
```
### Profile tests
```bash
dottrace start --framework=NetCore --profiling-type=Sampling \
--timeout=2m --save-to=./snapshots/tests.dtp \
-- dotnet test tests/NATS.Server.Core.Tests --filter "FullyQualifiedName~PubSub"
```
### Profile with child processes (e.g., server spawns workers)
```bash
dottrace start --framework=NetCore --profile-child \
--timeout=30s --save-to=./snapshots/nats-children.dtp \
-- dotnet run --project src/NATS.Server.Host
```
## Exporting Reports
dotTrace's XML report tool (Reporter.exe) is Windows-only. On macOS, use `dotnet-trace` for profiling with exportable formats:
```bash
# Install dotnet-trace
dotnet tool install --global dotnet-trace
# Collect a trace from a running process (nettrace format)
dotnet-trace collect --process-id <PID> --duration 00:00:30
# Collect directly in speedscope format
dotnet-trace collect --process-id <PID> --format speedscope --duration 00:00:30
# Convert an existing .nettrace file to speedscope
dotnet-trace convert --format speedscope trace.nettrace
```
Speedscope files can be visualized at [speedscope.app](https://www.speedscope.app) — a web-based flame graph viewer that works on any platform.
#### dotnet-trace output formats
| Format | Extension | Viewer |
|--------|-----------|--------|
| `nettrace` (default) | `.nettrace` | PerfView, Visual Studio, Rider |
| `speedscope` | `.speedscope.json` | [speedscope.app](https://www.speedscope.app) |
| `chromium` | `.chromium.json` | Chrome DevTools (`chrome://tracing`) |
#### Example: profile NATS server and export flame graph
```bash
# Start the server
dotnet run --project src/NATS.Server.Host -- -p 14222 &
SERVER_PID=$!
# Collect a 30-second trace in speedscope format
dotnet-trace collect --process-id $SERVER_PID --format speedscope \
--duration 00:00:30 --output ./snapshots/nats-trace
# Open the flame graph
open ./snapshots/nats-trace.speedscope.json # opens in default browser at speedscope.app
```
## Viewing Snapshots
Open `.dtp` / `.dtt` snapshot files in:
- **dotTrace GUI** (`/Users/dohertj2/Applications/dotTrace.app`)
- **JetBrains Rider** (built-in profiler viewer)
```bash
open /Users/dohertj2/Applications/dotTrace.app --args ./snapshots/nats-sampling.dtp
```
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 65 | Profiling failure |
## Notes
- Snapshots consist of multiple files: `*.dtp`, `*.dtp.0000`, `*.dtp.0001`, etc. Keep them together.
- Attach on macOS requires .NET 5 or later.
- Use `--` before the executable path if arguments start with `-`.
- The `snapshots/` directory is not tracked in git. Create it before profiling:
```bash
mkdir -p snapshots
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1982 -12682
View File
File diff suppressed because it is too large Load Diff
+78 -8988
View File
File diff suppressed because it is too large Load Diff
+219
View File
@@ -10,14 +10,49 @@ public sealed class Account : IDisposable
public const string SystemAccountName = "$SYS"; public const string SystemAccountName = "$SYS";
public const string ClientInfoHdr = "Nats-Request-Info"; 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; } public string Name { get; }
/// <summary>
/// Gets the subscription index for this account's subject interest.
/// </summary>
public SubList SubList { get; } = new(); 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; } 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 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 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(); public ExportMap Exports { get; } = new();
/// <summary>
/// Gets the import configuration (services/streams) this account consumes from other accounts.
/// </summary>
public ImportMap Imports { get; } = new(); 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 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; } public string? JetStreamTier { get; set; }
/// <summary> /// <summary>
@@ -31,8 +66,19 @@ public sealed class Account : IDisposable
public AccountLimits JetStreamLimits { get; set; } = AccountLimits.Unlimited; public AccountLimits JetStreamLimits { get; set; } = AccountLimits.Unlimited;
// JWT fields // JWT fields
/// <summary>
/// Gets or sets the account NKey identity from JWT/account configuration.
/// </summary>
public string? Nkey { get; set; } 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; } 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; } public Dictionary<string, object>? SigningKeys { get; set; }
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal); 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> /// <remarks>Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934).</remarks>
private const string GlobalRevocationKey = "*"; 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; public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
/// <summary> /// <summary>
@@ -48,8 +99,14 @@ public sealed class Account : IDisposable
/// up to the given timestamp. /// up to the given timestamp.
/// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887). /// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887).
/// </summary> /// </summary>
/// <param name="issuedBefore">JWT issued-at cutoff (Unix seconds) for global revocation.</param>
public void RevokeAllUsers(long issuedBefore) => _revokedUsers[GlobalRevocationKey] = issuedBefore; 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) public bool IsUserRevoked(string userNkey, long issuedAt)
{ {
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt)) if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
@@ -74,6 +131,7 @@ public sealed class Account : IDisposable
/// Removes the revocation entry for <paramref name="userNkey"/>. /// Removes the revocation entry for <paramref name="userNkey"/>.
/// Returns <see langword="true"/> if the entry was found and removed. /// Returns <see langword="true"/> if the entry was found and removed.
/// </summary> /// </summary>
/// <param name="userNkey">User NKey whose revocation record should be removed.</param>
public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _); public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _);
/// <summary>Removes all revocation entries, including any global ("*") revocation.</summary> /// <summary>Removes all revocation entries, including any global ("*") revocation.</summary>
@@ -89,18 +147,42 @@ public sealed class Account : IDisposable
private int _consumerCount; private int _consumerCount;
private long _storageUsed; 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) public Account(string name)
{ {
Name = name; Name = name;
} }
/// <summary>
/// Gets the number of currently connected clients in this account.
/// </summary>
public int ClientCount => _clients.Count; public int ClientCount => _clients.Count;
/// <summary>
/// Gets the number of active subscriptions tracked for this account.
/// </summary>
public int SubscriptionCount => Volatile.Read(ref _subscriptionCount); 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); 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); 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); public long StorageUsed => Interlocked.Read(ref _storageUsed);
/// <summary>Returns false if max connections exceeded.</summary> /// <summary>Returns false if max connections exceeded.</summary>
/// <param name="clientId">Client identifier to register in this account.</param>
public bool AddClient(ulong clientId) public bool AddClient(ulong clientId)
{ {
if (MaxConnections > 0 && _clients.Count >= MaxConnections) if (MaxConnections > 0 && _clients.Count >= MaxConnections)
@@ -109,8 +191,15 @@ public sealed class Account : IDisposable
return true; 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 _); public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);
/// <summary>
/// Attempts to increment the subscription count while honoring account limits.
/// </summary>
public bool IncrementSubscriptions() public bool IncrementSubscriptions()
{ {
if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions) if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
@@ -119,6 +208,9 @@ public sealed class Account : IDisposable
return true; return true;
} }
/// <summary>
/// Decrements the subscription count after an unsubscribe/removal.
/// </summary>
public void DecrementSubscriptions() public void DecrementSubscriptions()
{ {
Interlocked.Decrement(ref _subscriptionCount); Interlocked.Decrement(ref _subscriptionCount);
@@ -141,6 +233,9 @@ public sealed class Account : IDisposable
return true; return true;
} }
/// <summary>
/// Releases one previously reserved JetStream stream slot.
/// </summary>
public void ReleaseStream() public void ReleaseStream()
{ {
if (Volatile.Read(ref _jetStreamStreamCount) == 0) if (Volatile.Read(ref _jetStreamStreamCount) == 0)
@@ -160,6 +255,9 @@ public sealed class Account : IDisposable
return true; return true;
} }
/// <summary>
/// Releases one previously reserved JetStream consumer slot.
/// </summary>
public void ReleaseConsumer() public void ReleaseConsumer()
{ {
if (Volatile.Read(ref _consumerCount) == 0) 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"/>. /// Returns false if the positive delta would exceed <see cref="AccountLimits.MaxStorage"/>.
/// A negative delta always succeeds. /// A negative delta always succeeds.
/// </summary> /// </summary>
/// <param name="deltaBytes">Signed byte delta to apply to tracked storage usage.</param>
public bool TrackStorageDelta(long deltaBytes) public bool TrackStorageDelta(long deltaBytes)
{ {
var maxStorage = JetStreamLimits.MaxStorage; var maxStorage = JetStreamLimits.MaxStorage;
@@ -193,6 +292,9 @@ public sealed class Account : IDisposable
// Reference: Go server/accounts.go — account generation tracking for permission invalidation. // Reference: Go server/accounts.go — account generation tracking for permission invalidation.
private long _generationId; private long _generationId;
/// <summary>
/// Gets the permission-generation value used to invalidate per-client caches.
/// </summary>
public long GenerationId => Interlocked.Read(ref _generationId); public long GenerationId => Interlocked.Read(ref _generationId);
/// <summary>Increments the generation counter, signalling that permission caches are stale.</summary> /// <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 // Go reference: server/client.go — handleSlowConsumer, markConnAsSlow, server/accounts.go slowConsumerCount
private long _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); public long SlowConsumerCount => Interlocked.Read(ref _slowConsumerCount);
/// <summary>
/// Increments the slow-consumer counter for this account.
/// </summary>
public void IncrementSlowConsumers() => Interlocked.Increment(ref _slowConsumerCount); public void IncrementSlowConsumers() => Interlocked.Increment(ref _slowConsumerCount);
/// <summary>
/// Resets the slow-consumer counter to zero.
/// </summary>
public void ResetSlowConsumerCount() => Interlocked.Exchange(ref _slowConsumerCount, 0L); public void ResetSlowConsumerCount() => Interlocked.Exchange(ref _slowConsumerCount, 0L);
// Per-account message/byte stats // Per-account message/byte stats
@@ -214,17 +325,42 @@ public sealed class Account : IDisposable
private long _inBytes; private long _inBytes;
private long _outBytes; private long _outBytes;
/// <summary>
/// Gets total inbound messages observed for this account.
/// </summary>
public long InMsgs => Interlocked.Read(ref _inMsgs); public long InMsgs => Interlocked.Read(ref _inMsgs);
/// <summary>
/// Gets total outbound messages observed for this account.
/// </summary>
public long OutMsgs => Interlocked.Read(ref _outMsgs); 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); 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); 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) public void IncrementInbound(long msgs, long bytes)
{ {
Interlocked.Add(ref _inMsgs, msgs); Interlocked.Add(ref _inMsgs, msgs);
Interlocked.Add(ref _inBytes, bytes); 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) public void IncrementOutbound(long msgs, long bytes)
{ {
Interlocked.Add(ref _outMsgs, msgs); Interlocked.Add(ref _outMsgs, msgs);
@@ -234,6 +370,10 @@ public sealed class Account : IDisposable
// Internal (ACCOUNT) client for import/export message routing // Internal (ACCOUNT) client for import/export message routing
private InternalClient? _internalClient; 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) public InternalClient GetOrCreateInternalClient(ulong clientId)
{ {
if (_internalClient != null) return _internalClient; if (_internalClient != null) return _internalClient;
@@ -243,9 +383,13 @@ public sealed class Account : IDisposable
// Service export latency tracking // Service export latency tracking
// Go reference: accounts.go serviceLatency / serviceExportLatencyStats. // Go reference: accounts.go serviceLatency / serviceExportLatencyStats.
/// <summary>
/// Gets the service latency tracker for this account's exported services.
/// </summary>
public ServiceLatencyTracker LatencyTracker { get; } = new(); public ServiceLatencyTracker LatencyTracker { get; } = new();
/// <summary>Records a service request latency sample on this account's tracker.</summary> /// <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); public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs);
/// <summary> /// <summary>
@@ -265,6 +409,7 @@ public sealed class Account : IDisposable
/// Does not apply wildcard matching. /// Does not apply wildcard matching.
/// Go reference: accounts.go getServiceExport (direct map lookup only). /// Go reference: accounts.go getServiceExport (direct map lookup only).
/// </summary> /// </summary>
/// <param name="subject">Service subject to resolve.</param>
public ServiceExportInfo? GetExactServiceExport(string subject) public ServiceExportInfo? GetExactServiceExport(string subject)
{ {
if (Exports.Services.TryGetValue(subject, out var se)) 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. /// wildcard matching. Returns null when no export pattern matches.
/// Go reference: accounts.go getWildcardServiceExport (line 2849). /// Go reference: accounts.go getWildcardServiceExport (line 2849).
/// </summary> /// </summary>
/// <param name="subject">Service subject to match against export patterns.</param>
public ServiceExportInfo? GetWildcardServiceExport(string subject) public ServiceExportInfo? GetWildcardServiceExport(string subject)
{ {
// First try exact match // 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. /// Returns true when any service export (exact or wildcard) matches the given subject.
/// Go reference: accounts.go getServiceExport. /// Go reference: accounts.go getServiceExport.
/// </summary> /// </summary>
/// <param name="subject">Service subject to test.</param>
public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null; public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null;
private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se) 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); 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) public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved, ServiceLatency? latency = null)
{ {
var auth = new ExportAuth 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) public void AddStreamExport(string subject, IEnumerable<Account>? approved)
{ {
var auth = new ExportAuth var auth = new ExportAuth
@@ -335,6 +494,9 @@ public sealed class Account : IDisposable
/// Adds a service import with cycle detection. /// Adds a service import with cycle detection.
/// Go reference: accounts.go addServiceImport with checkForImportCycle. /// Go reference: accounts.go addServiceImport with checkForImportCycle.
/// </summary> /// </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="InvalidOperationException">Thrown if no export found or import would create a cycle.</exception>
/// <exception cref="UnauthorizedAccessException">Thrown if this account is not authorized.</exception> /// <exception cref="UnauthorizedAccessException">Thrown if this account is not authorized.</exception>
public ServiceImport AddServiceImport(Account destination, string from, string to) 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> /// <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> /// <returns>True if the import was found and removed.</returns>
public bool RemoveServiceImport(string from) public bool RemoveServiceImport(string from)
{ {
return Imports.Services.Remove(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) public void AddStreamImport(Account source, string from, string to)
{ {
if (!source.Exports.Streams.TryGetValue(from, out var export)) 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> /// <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> /// <returns>True if the import was found and removed.</returns>
public bool RemoveStreamImport(string from) 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. /// 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. /// Go reference: accounts.go streamImportFormsCycle / checkStreamImportsForCycles.
/// </summary> /// </summary>
/// <param name="proposedSource">Source account being considered for a new stream import.</param>
public bool StreamImportFormsCycle(Account proposedSource) public bool StreamImportFormsCycle(Account proposedSource)
{ {
ArgumentNullException.ThrowIfNull(proposedSource); ArgumentNullException.ThrowIfNull(proposedSource);
@@ -448,11 +619,15 @@ public sealed class Account : IDisposable
/// <summary> /// <summary>
/// Returns true if this account has at least one stream import from the account with the given name. /// Returns true if this account has at least one stream import from the account with the given name.
/// </summary> /// </summary>
/// <param name="accountName">Source account name to check for stream-import relationships.</param>
public bool HasStreamImportFrom(string accountName) => public bool HasStreamImportFrom(string accountName) =>
Imports.Streams.Exists(si => string.Equals(si.SourceAccount.Name, accountName, StringComparison.Ordinal)); Imports.Streams.Exists(si => string.Equals(si.SourceAccount.Name, accountName, StringComparison.Ordinal));
// Per-subject service response thresholds. // Per-subject service response thresholds.
// Go reference: server/accounts.go — serviceExport.respThresh, SetServiceExportResponseThreshold, ServiceExportResponseThreshold. // 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; } = public ConcurrentDictionary<string, TimeSpan> ServiceResponseThresholds { get; } =
new(StringComparer.Ordinal); new(StringComparer.Ordinal);
@@ -460,6 +635,8 @@ public sealed class Account : IDisposable
/// Sets the maximum time a service export responder may take to reply. /// Sets the maximum time a service export responder may take to reply.
/// Go reference: accounts.go SetServiceExportResponseThreshold (~line 2522). /// Go reference: accounts.go SetServiceExportResponseThreshold (~line 2522).
/// </summary> /// </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) => public void SetServiceResponseThreshold(string subject, TimeSpan threshold) =>
ServiceResponseThresholds[subject] = 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. /// Returns the threshold for <paramref name="subject"/>, or <see langword="null"/> if none is set.
/// Go reference: accounts.go ServiceExportResponseThreshold (~line 2510). /// Go reference: accounts.go ServiceExportResponseThreshold (~line 2510).
/// </summary> /// </summary>
/// <param name="subject">Service subject to query for an explicit threshold.</param>
public TimeSpan? GetServiceResponseThreshold(string subject) => public TimeSpan? GetServiceResponseThreshold(string subject) =>
ServiceResponseThresholds.TryGetValue(subject, out var t) ? t : null; 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. /// 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. /// Go reference: accounts.go — respThresh check inside response-timer logic.
/// </summary> /// </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) public bool IsServiceResponseOverdue(string subject, TimeSpan elapsed)
{ {
if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold)) 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. /// Combines threshold lookup and overdue check into a single result.
/// Go reference: accounts.go — ServiceExportResponseThreshold + response-timer logic. /// Go reference: accounts.go — ServiceExportResponseThreshold + response-timer logic.
/// </summary> /// </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) public ServiceResponseThresholdResult CheckServiceResponse(string subject, TimeSpan elapsed)
{ {
if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold)) if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold))
@@ -552,6 +734,7 @@ public sealed class Account : IDisposable
/// Sets the UTC expiration time for this account. /// Sets the UTC expiration time for this account.
/// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment. /// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment.
/// </summary> /// </summary>
/// <param name="expiresAtUtc">UTC timestamp when the account should expire.</param>
public void SetExpiration(DateTime expiresAtUtc) => public void SetExpiration(DateTime expiresAtUtc) =>
Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks); 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>. /// Convenience method: sets the expiration to <c>DateTime.UtcNow + <paramref name="ttl"/></c>.
/// Go reference: accounts.go — SetExpirationTimer with duration argument. /// Go reference: accounts.go — SetExpirationTimer with duration argument.
/// </summary> /// </summary>
/// <param name="ttl">Duration from now until account expiration.</param>
public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl); public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl);
/// <summary> /// <summary>
@@ -589,6 +773,8 @@ public sealed class Account : IDisposable
/// Registers a JWT activation claim for the given subject. /// Registers a JWT activation claim for the given subject.
/// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens. /// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens.
/// </summary> /// </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) => public void RegisterActivation(string subject, ActivationClaim claim) =>
_activations[subject] = 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. /// Returns a result indicating whether the claim was found and whether it is expired.
/// Go reference: accounts.go — checkActivation (~line 2943): act.Expires &lt;= tn ⇒ expired. /// Go reference: accounts.go — checkActivation (~line 2943): act.Expires &lt;= tn ⇒ expired.
/// </summary> /// </summary>
/// <param name="subject">Service or stream subject whose activation should be checked.</param>
public ActivationCheckResult CheckActivationExpiry(string subject) public ActivationCheckResult CheckActivationExpiry(string subject)
{ {
if (!_activations.TryGetValue(subject, out var claim)) if (!_activations.TryGetValue(subject, out var claim))
@@ -612,6 +799,7 @@ public sealed class Account : IDisposable
/// and has passed its expiry time. /// and has passed its expiry time.
/// Go reference: accounts.go — act.Expires &lt;= tn check inside checkActivation. /// Go reference: accounts.go — act.Expires &lt;= tn check inside checkActivation.
/// </summary> /// </summary>
/// <param name="subject">Service or stream subject whose activation should be checked.</param>
public bool IsActivationExpired(string subject) => public bool IsActivationExpired(string subject) =>
_activations.TryGetValue(subject, out var claim) && claim.IsExpired; _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. /// incremented so that per-client permission caches are invalidated.
/// Go reference: server/accounts.go UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287). /// Go reference: server/accounts.go UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287).
/// </summary> /// </summary>
/// <param name="newClaims">Fresh account claim snapshot to apply.</param>
public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims) public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims)
{ {
ArgumentNullException.ThrowIfNull(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. /// Records which origin account and original reply subject to route the response back to.
/// Go reference: accounts.go addRespMapEntry. /// Go reference: accounts.go addRespMapEntry.
/// </summary> /// </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) => public void AddReverseRespMapEntry(string replySubject, string originAccount, string originalReply) =>
_reverseResponseMap[replySubject] = new ReverseResponseMapEntry( _reverseResponseMap[replySubject] = new ReverseResponseMapEntry(
replySubject, originAccount, originalReply, DateTime.UtcNow); replySubject, originAccount, originalReply, DateTime.UtcNow);
@@ -760,6 +952,7 @@ public sealed class Account : IDisposable
/// Returns <see langword="null"/> when no mapping exists. /// Returns <see langword="null"/> when no mapping exists.
/// Go reference: accounts.go checkForReverseEntries. /// Go reference: accounts.go checkForReverseEntries.
/// </summary> /// </summary>
/// <param name="replySubject">Rewritten reply subject to resolve back to origin details.</param>
public ReverseResponseMapEntry? CheckForReverseEntries(string replySubject) => public ReverseResponseMapEntry? CheckForReverseEntries(string replySubject) =>
_reverseResponseMap.TryGetValue(replySubject, out var entry) ? entry : null; _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"/>. /// Removes the reverse response mapping for <paramref name="replySubject"/>.
/// Returns <see langword="true"/> if the entry was found and removed. /// Returns <see langword="true"/> if the entry was found and removed.
/// </summary> /// </summary>
/// <param name="replySubject">Rewritten reply subject whose reverse mapping should be removed.</param>
public bool RemoveReverseRespMapEntry(string replySubject) => public bool RemoveReverseRespMapEntry(string replySubject) =>
_reverseResponseMap.TryRemove(replySubject, out _); _reverseResponseMap.TryRemove(replySubject, out _);
@@ -785,6 +979,7 @@ public sealed class Account : IDisposable
/// from receiving them. /// from receiving them.
/// Go reference: accounts.go serviceImportShadowed (~line 2015). /// Go reference: accounts.go serviceImportShadowed (~line 2015).
/// </summary> /// </summary>
/// <param name="importSubject">Service import subject to test for local shadowing.</param>
public bool ServiceImportShadowed(string importSubject) public bool ServiceImportShadowed(string importSubject)
{ {
var matchResult = SubList.Match(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. /// Returns true if this account has at least one matching subscription for the given subject.
/// Go reference: accounts.go SubscriptionInterest. /// Go reference: accounts.go SubscriptionInterest.
/// </summary> /// </summary>
/// <param name="subject">Subject to test for local subscription interest.</param>
public bool SubscriptionInterest(string subject) => Interest(subject) > 0; public bool SubscriptionInterest(string subject) => Interest(subject) > 0;
/// <summary> /// <summary>
/// Returns the total number of matching subscriptions (plain + queue) for the given subject. /// Returns the total number of matching subscriptions (plain + queue) for the given subject.
/// Go reference: accounts.go Interest. /// Go reference: accounts.go Interest.
/// </summary> /// </summary>
/// <param name="subject">Subject to count matching local subscriptions for.</param>
public int Interest(string subject) public int Interest(string subject)
{ {
var (plainCount, queueCount) = SubList.NumInterest(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. /// When <paramref name="filter"/> is empty, counts all mappings.
/// Go reference: accounts.go NumPendingResponses. /// Go reference: accounts.go NumPendingResponses.
/// </summary> /// </summary>
/// <param name="filter">Optional service subject filter; empty counts all response mappings.</param>
public int NumPendingResponses(string filter) public int NumPendingResponses(string filter)
{ {
if (string.IsNullOrEmpty(filter)) if (string.IsNullOrEmpty(filter))
@@ -847,6 +1045,8 @@ public sealed class Account : IDisposable
/// Removes a response service import mapping. /// Removes a response service import mapping.
/// Go reference: accounts.go removeRespServiceImport. /// Go reference: accounts.go removeRespServiceImport.
/// </summary> /// </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) public void RemoveRespServiceImport(ServiceImport? serviceImport, ResponseServiceImportRemovalReason reason = ResponseServiceImportRemovalReason.Ok)
{ {
if (serviceImport == null) if (serviceImport == null)
@@ -924,6 +1124,7 @@ public sealed class Account : IDisposable
/// including the list of local subscription subjects that shadow it. /// including the list of local subscription subjects that shadow it.
/// Go reference: accounts.go serviceImportShadowed (~line 2015). /// Go reference: accounts.go serviceImportShadowed (~line 2015).
/// </summary> /// </summary>
/// <param name="importSubject">Service import subject to inspect for shadowing details.</param>
public ShadowCheckResult CheckServiceImportShadowing(string importSubject) public ShadowCheckResult CheckServiceImportShadowing(string importSubject)
{ {
var matchResult = SubList.Match(importSubject); var matchResult = SubList.Match(importSubject);
@@ -940,6 +1141,9 @@ public sealed class Account : IDisposable
return new ShadowCheckResult(isShadowed, importSubject, shadowingSubs); return new ShadowCheckResult(isShadowed, importSubject, shadowingSubs);
} }
/// <summary>
/// Disposes account-owned resources, including the subscription index.
/// </summary>
public void Dispose() => SubList.Dispose(); public void Dispose() => SubList.Dispose();
} }
@@ -1006,9 +1210,24 @@ public sealed record RevocationInfo(
/// </summary> /// </summary>
public sealed class ActivationClaim public sealed class ActivationClaim
{ {
/// <summary>
/// Gets the activated subject path this claim authorizes.
/// </summary>
public required string Subject { get; init; } public required string Subject { get; init; }
/// <summary>
/// Gets when the activation was issued.
/// </summary>
public required DateTime IssuedAt { get; init; } public required DateTime IssuedAt { get; init; }
/// <summary>
/// Gets when the activation expires.
/// </summary>
public required DateTime ExpiresAt { get; init; } public required DateTime ExpiresAt { get; init; }
/// <summary>
/// Gets the issuer key associated with this activation claim.
/// </summary>
public string? Issuer { get; init; } public string? Issuer { get; init; }
/// <summary> /// <summary>
@@ -38,6 +38,8 @@ public static class ConfigReloader
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change /// a list of <see cref="IConfigChange"/> for every property that differs. Each change
/// is tagged with the appropriate category flags. /// is tagged with the appropriate category flags.
/// </summary> /// </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) public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
{ {
var changes = new List<IConfigChange>(); 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 /// Validates a list of config changes and returns error messages for any
/// non-reloadable changes (properties that require a server restart). /// non-reloadable changes (properties that require a server restart).
/// </summary> /// </summary>
/// <param name="changes">Detected config differences to validate for reload safety.</param>
public static List<string> Validate(List<IConfigChange> changes) public static List<string> Validate(List<IConfigChange> changes)
{ {
var errors = new List<string>(); 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"/> /// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>. /// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
/// </summary> /// </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) public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
{ {
foreach (var flag in cliFlags) foreach (var flag in cliFlags)
@@ -337,6 +343,9 @@ public static class ConfigReloader
/// flags indicating which subsystems need to be notified. /// flags indicating which subsystems need to be notified.
/// Reference: Go server/reload.go — applyOptions. /// Reference: Go server/reload.go — applyOptions.
/// </summary> /// </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( public static ConfigApplyResult ApplyDiff(
List<IConfigChange> changes, List<IConfigChange> changes,
NatsOptions currentOpts, NatsOptions currentOpts,
@@ -366,6 +375,12 @@ public static class ConfigReloader
/// the SIGHUP handler) is responsible for applying the result to the running server. /// the SIGHUP handler) is responsible for applying the result to the running server.
/// Reference: Go server/reload.go — Reload. /// Reference: Go server/reload.go — Reload.
/// </summary> /// </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( public static async Task<ConfigReloadResult> ReloadAsync(
string configFile, string configFile,
NatsOptions currentOpts, NatsOptions currentOpts,
@@ -403,6 +418,8 @@ public static class ConfigReloader
/// a reload result indicating whether the change is valid. /// a reload result indicating whether the change is valid.
/// Go reference: server/reload.go — Reload with in-memory options comparison. /// Go reference: server/reload.go — Reload with in-memory options comparison.
/// </summary> /// </summary>
/// <param name="original">Original options baseline.</param>
/// <param name="updated">Updated options candidate.</param>
public static Task<ReloadFromOptionsResult> ReloadFromOptionsAsync(NatsOptions original, NatsOptions updated) public static Task<ReloadFromOptionsResult> ReloadFromOptionsAsync(NatsOptions original, NatsOptions updated)
{ {
var changes = Diff(original, 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. /// Callers use this to reconcile route/gateway/leaf connections after a hot reload.
/// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply. /// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply.
/// </summary> /// </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) public static ClusterConfigChangeResult ApplyClusterConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
{ {
var result = new ClusterConfigChangeResult(); var result = new ClusterConfigChangeResult();
@@ -471,6 +490,8 @@ public static class ConfigReloader
/// Debug → "Debug", otherwise "Information" — matching Go's precedence. /// Debug → "Debug", otherwise "Information" — matching Go's precedence.
/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply. /// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply.
/// </summary> /// </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) public static LoggingChangeResult ApplyLoggingChanges(NatsOptions oldOpts, NatsOptions newOpts)
{ {
var result = new LoggingChangeResult(); var result = new LoggingChangeResult();
@@ -598,6 +619,8 @@ public static class ConfigReloader
/// re-evaluation of existing connections after a config reload. /// re-evaluation of existing connections after a config reload.
/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply. /// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply.
/// </summary> /// </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) public static AuthChangeResult PropagateAuthChanges(NatsOptions oldOpts, NatsOptions newOpts)
{ {
var result = new AuthChangeResult(); var result = new AuthChangeResult();
@@ -636,6 +659,8 @@ public static class ConfigReloader
/// If changed, validates the new cert is loadable. /// If changed, validates the new cert is loadable.
/// Go reference: server/reload.go — tlsConfigReload. /// Go reference: server/reload.go — tlsConfigReload.
/// </summary> /// </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) public static TlsReloadResult ReloadTlsCertificates(NatsOptions oldOpts, NatsOptions newOpts)
{ {
var result = new TlsReloadResult(); var result = new TlsReloadResult();
@@ -672,6 +697,8 @@ public static class ConfigReloader
/// existing connections keep their original certificate. /// existing connections keep their original certificate.
/// Reference: golang/nats-server/server/reload.go — tlsOption.Apply. /// Reference: golang/nats-server/server/reload.go — tlsOption.Apply.
/// </summary> /// </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( public static bool ReloadTlsCertificate(
NatsOptions options, NatsOptions options,
TlsCertificateProvider? certProvider) TlsCertificateProvider? certProvider)
@@ -696,6 +723,8 @@ public static class ConfigReloader
/// hot reload without requiring a server restart. /// hot reload without requiring a server restart.
/// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply. /// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply.
/// </summary> /// </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) public static JetStreamConfigChangeResult ApplyJetStreamConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
{ {
var result = new JetStreamConfigChangeResult(); var result = new JetStreamConfigChangeResult();
@@ -753,12 +782,34 @@ public readonly record struct ConfigApplyResult(
/// </summary> /// </summary>
public sealed class ConfigReloadResult public sealed class ConfigReloadResult
{ {
/// <summary>
/// Gets whether reload was skipped because the config digest did not change.
/// </summary>
public bool Unchanged { get; } public bool Unchanged { get; }
/// <summary>
/// Gets newly parsed options when a reload candidate was produced.
/// </summary>
public NatsOptions? NewOptions { get; } public NatsOptions? NewOptions { get; }
/// <summary>
/// Gets the digest for the parsed config file.
/// </summary>
public string? NewDigest { get; } public string? NewDigest { get; }
/// <summary>
/// Gets the detected config changes for this reload attempt.
/// </summary>
public List<IConfigChange>? Changes { get; } public List<IConfigChange>? Changes { get; }
/// <summary>
/// Gets validation errors detected while evaluating the reload.
/// </summary>
public List<string>? Errors { get; } public List<string>? Errors { get; }
/// <summary>
/// Initializes a config reload result payload.
/// </summary>
public ConfigReloadResult( public ConfigReloadResult(
bool Unchanged, bool Unchanged,
NatsOptions? NewOptions = null, NatsOptions? NewOptions = null,
@@ -773,6 +824,9 @@ public sealed class ConfigReloadResult
this.Errors = Errors; this.Errors = Errors;
} }
/// <summary>
/// Gets whether this reload result contains validation errors.
/// </summary>
public bool HasErrors => Errors is { Count: > 0 }; public bool HasErrors => Errors is { Count: > 0 };
} }
@@ -1,23 +1,75 @@
namespace NATS.Server.Configuration; namespace NATS.Server.Configuration;
/// <summary>
/// Configuration for a gateway listener and outbound gateway connections to other clusters.
/// </summary>
public sealed class GatewayOptions public sealed class GatewayOptions
{ {
/// <summary>
/// Local gateway name advertised to remote clusters.
/// </summary>
public string? Name { get; set; } 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"; public string Host { get; set; } = "0.0.0.0";
/// <summary>
/// TCP port used by the gateway listener.
/// </summary>
public int Port { get; set; } public int Port { get; set; }
/// <summary>
/// Remote gateway URLs from configuration.
/// </summary>
public List<string> Remotes { get; set; } = []; public List<string> Remotes { get; set; } = [];
// Go: opts.go — gateway authorization fields // Go: opts.go — gateway authorization fields
/// <summary>
/// Rejects inbound gateway connections from clusters that are not explicitly configured.
/// </summary>
public bool RejectUnknown { get; set; } public bool RejectUnknown { get; set; }
/// <summary>
/// Username for gateway authentication.
/// </summary>
public string? Username { get; set; } public string? Username { get; set; }
/// <summary>
/// Password for gateway authentication.
/// </summary>
public string? Password { get; set; } public string? Password { get; set; }
/// <summary>
/// Authentication timeout, in seconds, for gateway handshakes.
/// </summary>
public double AuthTimeout { get; set; } 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; } public string? Advertise { get; set; }
/// <summary>
/// Maximum number of outbound connection retries before giving up.
/// </summary>
public int ConnectRetries { get; set; } public int ConnectRetries { get; set; }
/// <summary>
/// Enables backoff between outbound gateway reconnect attempts.
/// </summary>
public bool ConnectBackoff { get; set; } public bool ConnectBackoff { get; set; }
/// <summary>
/// Write deadline applied to outbound gateway socket writes.
/// </summary>
public TimeSpan WriteDeadline { get; set; } public TimeSpan WriteDeadline { get; set; }
// Go: opts.go — gateways remotes list (RemoteGatewayOpts) // Go: opts.go — gateways remotes list (RemoteGatewayOpts)
/// <summary>
/// Expanded remote gateway definitions with runtime metadata.
/// </summary>
public List<RemoteGatewayOptions> RemoteGateways { get; set; } = []; public List<RemoteGatewayOptions> RemoteGateways { get; set; } = [];
} }
@@ -28,12 +80,39 @@ public sealed class RemoteGatewayOptions
{ {
private int _connAttempts; private int _connAttempts;
/// <summary>
/// Remote gateway cluster name.
/// </summary>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Normalized remote URLs for this gateway.
/// </summary>
public List<string> Urls { get; set; } = []; public List<string> Urls { get; set; } = [];
/// <summary>
/// Indicates that this remote was discovered implicitly rather than configured statically.
/// </summary>
public bool Implicit { get; set; } public bool Implicit { get; set; }
/// <summary>
/// Current hash of the URL set used for change detection.
/// </summary>
public byte[]? Hash { get; set; } public byte[]? Hash { get; set; }
/// <summary>
/// Previous hash value retained across URL updates.
/// </summary>
public byte[]? OldHash { get; set; } public byte[]? OldHash { get; set; }
/// <summary>
/// TLS server name captured from a remote URL host.
/// </summary>
public string? TlsName { get; private set; } public string? TlsName { get; private set; }
/// <summary>
/// Indicates whether URL changes should be surfaced in gateway monitoring endpoints.
/// </summary>
public bool VarzUpdateUrls { get; set; } public bool VarzUpdateUrls { get; set; }
/// <summary> /// <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); public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts);
/// <summary>
/// Returns the current outbound connection attempt count.
/// </summary>
public int GetConnAttempts() => Volatile.Read(ref _connAttempts); public int GetConnAttempts() => Volatile.Read(ref _connAttempts);
/// <summary>
/// Resets outbound connection attempt tracking.
/// </summary>
public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0); public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0);
/// <summary>
/// Returns whether this remote gateway entry is implicit.
/// </summary>
public bool IsImplicit() => Implicit; 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) public List<Uri> GetUrls(Random? random = null)
{ {
var urls = new List<Uri>(); var urls = new List<Uri>();
@@ -81,6 +176,9 @@ public sealed class RemoteGatewayOptions
return urls; return urls;
} }
/// <summary>
/// Returns normalized URL strings for diagnostics and monitor payloads.
/// </summary>
public List<string> GetUrlsAsStrings() public List<string> GetUrlsAsStrings()
{ {
var result = new List<string>(); var result = new List<string>();
@@ -89,6 +187,11 @@ public sealed class RemoteGatewayOptions
return result; 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) public void UpdateUrls(IEnumerable<string> configuredUrls, IEnumerable<string> discoveredUrls)
{ {
var merged = new List<string>(); var merged = new List<string>();
@@ -97,12 +200,20 @@ public sealed class RemoteGatewayOptions
Urls = merged; 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) public void SaveTlsHostname(string url)
{ {
if (TryNormalizeRemoteUrl(url, out var uri)) if (TryNormalizeRemoteUrl(url, out var uri))
TlsName = uri.Host; 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) public void AddUrls(IEnumerable<string> discoveredUrls)
{ {
AddUrlsInternal(Urls, discoveredUrls); AddUrlsInternal(Urls, discoveredUrls);
@@ -14,12 +14,39 @@ namespace NATS.Server.Events;
/// </summary> /// </summary>
public sealed class PublishMessage public sealed class PublishMessage
{ {
/// <summary>
/// Gets optional originating internal client context for this publish.
/// </summary>
public InternalClient? Client { get; init; } public InternalClient? Client { get; init; }
/// <summary>
/// Gets the destination subject for the internal publish.
/// </summary>
public required string Subject { get; init; } public required string Subject { get; init; }
/// <summary>
/// Gets the optional reply subject.
/// </summary>
public string? Reply { get; init; } public string? Reply { get; init; }
/// <summary>
/// Gets optional header bytes for HMSG-style delivery.
/// </summary>
public byte[]? Headers { get; init; } public byte[]? Headers { get; init; }
/// <summary>
/// Gets the payload object to serialize and publish.
/// </summary>
public object? Body { get; init; } public object? Body { get; init; }
/// <summary>
/// Gets whether this event should be echoed back to the sender context.
/// </summary>
public bool Echo { get; init; } public bool Echo { get; init; }
/// <summary>
/// Gets whether this message is the final send-loop item before shutdown.
/// </summary>
public bool IsLast { get; init; } public bool IsLast { get; init; }
} }
@@ -28,13 +55,44 @@ public sealed class PublishMessage
/// </summary> /// </summary>
public sealed class InternalSystemMessage public sealed class InternalSystemMessage
{ {
/// <summary>
/// Gets the matched internal subscription.
/// </summary>
public required Subscription? Sub { get; init; } public required Subscription? Sub { get; init; }
/// <summary>
/// Gets the internal client delivering the message.
/// </summary>
public required INatsClient? Client { get; init; } public required INatsClient? Client { get; init; }
/// <summary>
/// Gets the account context for this internal dispatch.
/// </summary>
public required Account? Account { get; init; } public required Account? Account { get; init; }
/// <summary>
/// Gets the message subject.
/// </summary>
public required string Subject { get; init; } public required string Subject { get; init; }
/// <summary>
/// Gets the optional reply subject.
/// </summary>
public required string? Reply { get; init; } public required string? Reply { get; init; }
/// <summary>
/// Gets message header bytes.
/// </summary>
public required ReadOnlyMemory<byte> Headers { get; init; } public required ReadOnlyMemory<byte> Headers { get; init; }
/// <summary>
/// Gets message payload bytes.
/// </summary>
public required ReadOnlyMemory<byte> Message { get; init; } public required ReadOnlyMemory<byte> Message { get; init; }
/// <summary>
/// Gets callback invoked by the internal receive loop.
/// </summary>
public required SystemMessageHandler Callback { get; init; } public required SystemMessageHandler Callback { get; init; }
} }
@@ -113,8 +171,19 @@ public sealed class InternalEventSystem : IAsyncDisposable
private readonly ConcurrentDictionary<string, SystemMessageHandler> _callbacks = new(); private readonly ConcurrentDictionary<string, SystemMessageHandler> _callbacks = new();
private long _authErrorEventCount; private long _authErrorEventCount;
/// <summary>
/// Gets the system account used for advisory routing.
/// </summary>
public Account SystemAccount { get; } public Account SystemAccount { get; }
/// <summary>
/// Gets the internal system client bound to system subscriptions.
/// </summary>
public InternalClient SystemClient { get; } public InternalClient SystemClient { get; }
/// <summary>
/// Gets the hashed server identifier used in request/reply subjects.
/// </summary>
public string ServerHash { get; } public string ServerHash { get; }
/// <summary> /// <summary>
@@ -123,6 +192,13 @@ public sealed class InternalEventSystem : IAsyncDisposable
/// </summary> /// </summary>
public long AuthErrorEventCount => Interlocked.Read(ref _authErrorEventCount); 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) public InternalEventSystem(Account systemAccount, InternalClient systemClient, string serverName, ILogger logger)
{ {
_logger = logger; _logger = logger;
@@ -145,6 +221,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
/// <summary> /// <summary>
/// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers. /// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers.
/// </summary> /// </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) public static string GetHash(string value, int size)
{ {
ArgumentOutOfRangeException.ThrowIfLessThan(size, 1); ArgumentOutOfRangeException.ThrowIfLessThan(size, 1);
@@ -152,6 +230,10 @@ public sealed class InternalEventSystem : IAsyncDisposable
return size >= full.Length ? full : full[..size]; 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) public void Start(NatsServer server)
{ {
_server = 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 /// Sets up handlers for $SYS.REQ.SERVER.{id}.VARZ, HEALTHZ, SUBSZ, STATSZ, IDZ
/// and wildcard $SYS.REQ.SERVER.PING.* subjects. /// and wildcard $SYS.REQ.SERVER.PING.* subjects.
/// </summary> /// </summary>
/// <param name="server">Owning server that handles system request subjects.</param>
public void InitEventTracking(NatsServer server) public void InitEventTracking(NatsServer server)
{ {
_server = server; _server = server;
@@ -258,6 +341,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
/// Creates a system subscription in the system account's SubList. /// Creates a system subscription in the system account's SubList.
/// Maps to Go's sysSubscribe in events.go:2796. /// Maps to Go's sysSubscribe in events.go:2796.
/// </summary> /// </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) public Subscription SysSubscribe(string subject, SystemMessageHandler callback)
{ {
var sid = Interlocked.Increment(ref _subscriptionId).ToString(); 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. /// Increments <see cref="AuthErrorEventCount"/> each time it is called.
/// Go reference: events.go:2631 sendAuthErrorEvent. /// Go reference: events.go:2631 sendAuthErrorEvent.
/// </summary> /// </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) public void SendAuthErrorEvent(string serverId, AuthErrorDetail detail)
{ {
var subject = string.Format(EventSubjects.AuthError, serverId); 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. /// Publishes a client connect advisory to $SYS.ACCOUNT.{account}.CONNECT.
/// Go reference: events.go postConnectEvent / sendConnect. /// Go reference: events.go postConnectEvent / sendConnect.
/// </summary> /// </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) public void SendConnectEvent(string serverId, ConnectEventDetail detail)
{ {
var accountName = detail.AccountName ?? "$G"; var accountName = detail.AccountName ?? "$G";
@@ -363,6 +452,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
/// Publishes a client disconnect advisory to $SYS.ACCOUNT.{account}.DISCONNECT. /// Publishes a client disconnect advisory to $SYS.ACCOUNT.{account}.DISCONNECT.
/// Go reference: events.go postDisconnectEvent / sendDisconnect. /// Go reference: events.go postDisconnectEvent / sendDisconnect.
/// </summary> /// </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) public void SendDisconnectEvent(string serverId, DisconnectEventDetail detail)
{ {
var accountName = detail.AccountName ?? "$G"; var accountName = detail.AccountName ?? "$G";
@@ -396,6 +487,7 @@ public sealed class InternalEventSystem : IAsyncDisposable
/// <summary> /// <summary>
/// Enqueue an internal message for publishing through the send loop. /// Enqueue an internal message for publishing through the send loop.
/// </summary> /// </summary>
/// <param name="message">Internal publish message to queue.</param>
public void Enqueue(PublishMessage message) public void Enqueue(PublishMessage message)
{ {
_sendQueue.Writer.TryWrite(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() public async ValueTask DisposeAsync()
{ {
await _cts.CancelAsync(); await _cts.CancelAsync();
@@ -49,6 +49,7 @@ public class SequenceSet
public bool IsEmpty => Root == null; public bool IsEmpty => Root == null;
/// <summary>Insert will insert the sequence into the set. The tree will be balanced inline.</summary> /// <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) public void Insert(ulong seq)
{ {
Root = Node.Insert(Root, seq, ref _changed, ref _nodes); 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> /// <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) public bool Exists(ulong seq)
{ {
var n = Root; var n = Root;
@@ -86,6 +88,7 @@ public class SequenceSet
/// Sets the initial minimum sequence when known. More effectively utilizes space. /// Sets the initial minimum sequence when known. More effectively utilizes space.
/// The set must be empty. /// The set must be empty.
/// </summary> /// </summary>
/// <param name="min">Initial minimum sequence bucket base.</param>
public void SetInitialMin(ulong min) public void SetInitialMin(ulong min)
{ {
if (!IsEmpty) if (!IsEmpty)
@@ -100,6 +103,7 @@ public class SequenceSet
/// <summary> /// <summary>
/// Removes the sequence from the set. Returns true if the sequence was present. /// Removes the sequence from the set. Returns true if the sequence was present.
/// </summary> /// </summary>
/// <param name="seq">Sequence value to remove.</param>
public bool Delete(ulong seq) public bool Delete(ulong seq)
{ {
if (Root == null) if (Root == null)
@@ -135,6 +139,7 @@ public class SequenceSet
/// Invokes the callback for each item in ascending order. /// Invokes the callback for each item in ascending order.
/// If the callback returns false, iteration terminates. /// If the callback returns false, iteration terminates.
/// </summary> /// </summary>
/// <param name="callback">Callback invoked for each sequence in ascending order.</param>
public void Range(Func<ulong, bool> callback) => Node.Iter(Root, callback); public void Range(Func<ulong, bool> callback) => Node.Iter(Root, callback);
/// <summary>Returns the left and right heights of the tree root.</summary> /// <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> /// <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) public void Union(params SequenceSet[] others)
{ {
foreach (var other in others) foreach (var other in others)
@@ -225,6 +231,7 @@ public class SequenceSet
} }
/// <summary>Returns a union of all provided sets.</summary> /// <summary>Returns a union of all provided sets.</summary>
/// <param name="sets">Sets to merge.</param>
public static SequenceSet CreateUnion(params SequenceSet[] sets) public static SequenceSet CreateUnion(params SequenceSet[] sets)
{ {
if (sets.Length == 0) if (sets.Length == 0)
@@ -263,6 +270,7 @@ public class SequenceSet
/// Encodes the set into a caller-provided buffer. /// Encodes the set into a caller-provided buffer.
/// Returns the number of bytes written. /// Returns the number of bytes written.
/// </summary> /// </summary>
/// <param name="destination">Destination buffer for encoded bytes.</param>
public int Encode(byte[] destination) public int Encode(byte[] destination)
{ {
var encLen = EncodeLength(); 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> /// <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) public static (SequenceSet Set, int BytesRead) Decode(ReadOnlySpan<byte> buf)
{ {
if (buf.Length < MinLen || buf[0] != Magic) if (buf.Length < MinLen || buf[0] != Magic)
@@ -457,6 +466,8 @@ public class SequenceSet
public int Height; public int Height;
/// <summary>Sets the bit for the given sequence. Reports whether it was newly inserted.</summary> /// <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) public void SetBit(ulong seq, ref bool inserted)
{ {
seq -= Base; 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> /// <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) public bool ClearBit(ulong seq, ref bool deleted)
{ {
seq -= Base; seq -= Base;
@@ -493,6 +506,7 @@ public class SequenceSet
} }
/// <summary>Checks if the bit for the given sequence is set.</summary> /// <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) public bool ExistsBit(ulong seq)
{ {
seq -= Base; seq -= Base;
@@ -530,6 +544,10 @@ public class SequenceSet
} }
/// <summary>Inserts a sequence into the subtree rooted at this node, rebalancing as needed.</summary> /// <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) public static Node Insert(Node? n, ulong seq, ref bool inserted, ref int nodes)
{ {
if (n == null) 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> /// <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) public static Node? Delete(Node? n, ulong seq, ref bool deleted, ref int nodes)
{ {
if (n == null) if (n == null)
@@ -721,6 +743,7 @@ public class SequenceSet
} }
/// <summary>Returns the balance factor (left height - right height).</summary> /// <summary>Returns the balance factor (left height - right height).</summary>
/// <param name="n">Node to evaluate.</param>
internal static int BalanceFactor(Node? n) internal static int BalanceFactor(Node? n)
{ {
if (n == null) if (n == null)
@@ -734,6 +757,7 @@ public class SequenceSet
} }
/// <summary>Returns the max of left and right child heights.</summary> /// <summary>Returns the max of left and right child heights.</summary>
/// <param name="n">Node to evaluate.</param>
internal static int MaxHeight(Node? n) internal static int MaxHeight(Node? n)
{ {
if (n == null) if (n == null)
@@ -747,6 +771,8 @@ public class SequenceSet
} }
/// <summary>Iterates nodes in pre-order (root, left, right) for encoding.</summary> /// <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) internal static void NodeIter(Node? n, Action<Node> f)
{ {
if (n == null) if (n == null)
@@ -760,6 +786,8 @@ public class SequenceSet
} }
/// <summary>Iterates items in ascending order. Returns false if iteration was terminated early.</summary> /// <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) internal static bool Iter(Node? n, Func<ulong, bool> f)
{ {
if (n == null) if (n == null)
@@ -6,24 +6,87 @@ namespace NATS.Server.Internal.SubjectTree;
/// </summary> /// </summary>
internal interface INode internal interface INode
{ {
/// <summary>
/// Gets whether this node is a terminal subject node that directly stores a subscription value.
/// </summary>
bool IsLeaf { get; } bool IsLeaf { get; }
/// <summary>
/// Gets structural metadata for branch nodes, including compressed path prefix and child count.
/// </summary>
NodeMeta? Base { get; } 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); 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); void AddChild(byte c, INode n);
/// <summary> /// <summary>
/// Returns the child node for the given key byte, or null if not found. /// 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. /// The returned wrapper allows in-place replacement of the child reference.
/// </summary> /// </summary>
/// <param name="c">Subject byte to look up in the node's child index.</param>
ChildRef? FindChild(byte c); 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); 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; } bool IsFull { get; }
/// <summary>
/// Expands this node to a larger branching factor to accept more distinct subject bytes.
/// </summary>
INode Grow(); INode Grow();
/// <summary>
/// Attempts to shrink this node to a smaller branching representation when sparse.
/// </summary>
INode? Shrink(); 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); (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; } 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); void Iter(Func<INode, bool> f);
/// <summary>
/// Returns the current child nodes for traversal or inspection.
/// </summary>
INode?[] Children(); INode?[] Children();
/// <summary>
/// Gets the number of active child edges in this node.
/// </summary>
ushort NumChildren { get; } ushort NumChildren { get; }
/// <summary>
/// Gets the compressed path bytes represented by this node.
/// </summary>
byte[] Path(); byte[] Path();
} }
@@ -33,6 +96,9 @@ internal interface INode
/// </summary> /// </summary>
internal sealed class ChildRef(Func<INode?> getter, Action<INode?> setter) 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 public INode? Node
{ {
get => getter(); get => getter();
@@ -45,7 +111,14 @@ internal sealed class ChildRef(Func<INode?> getter, Action<INode?> setter)
/// </summary> /// </summary>
internal sealed class NodeMeta 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; } = []; 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; } public ushort Size { get; set; }
} }
@@ -60,28 +133,51 @@ internal sealed class Leaf<T> : INode
public T Value; public T Value;
public byte[] Suffix; 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) public Leaf(ReadOnlySpan<byte> suffix, T value)
{ {
Value = value; Value = value;
Suffix = Parts.CopyBytes(suffix); Suffix = Parts.CopyBytes(suffix);
} }
/// <inheritdoc />
public bool IsLeaf => true; public bool IsLeaf => true;
/// <inheritdoc />
public NodeMeta? Base => null; public NodeMeta? Base => null;
/// <inheritdoc />
public bool IsFull => true; public bool IsFull => true;
/// <inheritdoc />
public ushort NumChildren => 0; public ushort NumChildren => 0;
/// <inheritdoc />
public string Kind => "LEAF"; 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); 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); public void SetSuffix(ReadOnlySpan<byte> suffix) => Suffix = Parts.CopyBytes(suffix);
/// <inheritdoc />
public byte[] Path() => Suffix; public byte[] Path() => Suffix;
/// <inheritdoc />
public INode?[] Children() => []; public INode?[] Children() => [];
/// <inheritdoc />
public void Iter(Func<INode, bool> f) { } public void Iter(Func<INode, bool> f) { }
/// <inheritdoc />
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts) public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
=> Parts.MatchPartsAgainstFragment(parts, Suffix); => Parts.MatchPartsAgainstFragment(parts, Suffix);
@@ -108,23 +204,35 @@ internal sealed class Node4 : INode
private readonly byte[] _key = new byte[4]; private readonly byte[] _key = new byte[4];
internal readonly NodeMeta Meta = new(); 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) public Node4(ReadOnlySpan<byte> prefix)
{ {
SetPrefix(prefix); SetPrefix(prefix);
} }
/// <inheritdoc />
public bool IsLeaf => false; public bool IsLeaf => false;
/// <inheritdoc />
public NodeMeta? Base => Meta; public NodeMeta? Base => Meta;
/// <inheritdoc />
public ushort NumChildren => Meta.Size; public ushort NumChildren => Meta.Size;
/// <inheritdoc />
public bool IsFull => Meta.Size >= 4; public bool IsFull => Meta.Size >= 4;
/// <inheritdoc />
public string Kind => "NODE4"; public string Kind => "NODE4";
/// <inheritdoc />
public byte[] Path() => Meta.Prefix; public byte[] Path() => Meta.Prefix;
/// <inheritdoc />
public void SetPrefix(ReadOnlySpan<byte> pre) public void SetPrefix(ReadOnlySpan<byte> pre)
{ {
Meta.Prefix = pre.ToArray(); Meta.Prefix = pre.ToArray();
} }
/// <inheritdoc />
public void AddChild(byte c, INode n) public void AddChild(byte c, INode n)
{ {
if (Meta.Size >= 4) throw new InvalidOperationException("node4 full!"); if (Meta.Size >= 4) throw new InvalidOperationException("node4 full!");
@@ -133,6 +241,7 @@ internal sealed class Node4 : INode
Meta.Size++; Meta.Size++;
} }
/// <inheritdoc />
public ChildRef? FindChild(byte c) public ChildRef? FindChild(byte c)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -146,6 +255,7 @@ internal sealed class Node4 : INode
return null; return null;
} }
/// <inheritdoc />
public void DeleteChild(byte c) public void DeleteChild(byte c)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -171,6 +281,7 @@ internal sealed class Node4 : INode
} }
} }
/// <inheritdoc />
public INode Grow() public INode Grow()
{ {
var nn = new Node10(Meta.Prefix); var nn = new Node10(Meta.Prefix);
@@ -181,12 +292,14 @@ internal sealed class Node4 : INode
return nn; return nn;
} }
/// <inheritdoc />
public INode? Shrink() public INode? Shrink()
{ {
if (Meta.Size == 1) return _child[0]; if (Meta.Size == 1) return _child[0];
return null; return null;
} }
/// <inheritdoc />
public void Iter(Func<INode, bool> f) public void Iter(Func<INode, bool> f)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -195,6 +308,7 @@ internal sealed class Node4 : INode
} }
} }
/// <inheritdoc />
public INode?[] Children() public INode?[] Children()
{ {
var result = new INode?[Meta.Size]; var result = new INode?[Meta.Size];
@@ -202,6 +316,7 @@ internal sealed class Node4 : INode
return result; return result;
} }
/// <inheritdoc />
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts) public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix); => Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
} }
@@ -220,23 +335,35 @@ internal sealed class Node10 : INode
private readonly byte[] _key = new byte[10]; private readonly byte[] _key = new byte[10];
internal readonly NodeMeta Meta = new(); 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) public Node10(ReadOnlySpan<byte> prefix)
{ {
SetPrefix(prefix); SetPrefix(prefix);
} }
/// <inheritdoc />
public bool IsLeaf => false; public bool IsLeaf => false;
/// <inheritdoc />
public NodeMeta? Base => Meta; public NodeMeta? Base => Meta;
/// <inheritdoc />
public ushort NumChildren => Meta.Size; public ushort NumChildren => Meta.Size;
/// <inheritdoc />
public bool IsFull => Meta.Size >= 10; public bool IsFull => Meta.Size >= 10;
/// <inheritdoc />
public string Kind => "NODE10"; public string Kind => "NODE10";
/// <inheritdoc />
public byte[] Path() => Meta.Prefix; public byte[] Path() => Meta.Prefix;
/// <inheritdoc />
public void SetPrefix(ReadOnlySpan<byte> pre) public void SetPrefix(ReadOnlySpan<byte> pre)
{ {
Meta.Prefix = pre.ToArray(); Meta.Prefix = pre.ToArray();
} }
/// <inheritdoc />
public void AddChild(byte c, INode n) public void AddChild(byte c, INode n)
{ {
if (Meta.Size >= 10) throw new InvalidOperationException("node10 full!"); if (Meta.Size >= 10) throw new InvalidOperationException("node10 full!");
@@ -245,6 +372,7 @@ internal sealed class Node10 : INode
Meta.Size++; Meta.Size++;
} }
/// <inheritdoc />
public ChildRef? FindChild(byte c) public ChildRef? FindChild(byte c)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -258,6 +386,7 @@ internal sealed class Node10 : INode
return null; return null;
} }
/// <inheritdoc />
public void DeleteChild(byte c) public void DeleteChild(byte c)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -283,6 +412,7 @@ internal sealed class Node10 : INode
} }
} }
/// <inheritdoc />
public INode Grow() public INode Grow()
{ {
var nn = new Node16(Meta.Prefix); var nn = new Node16(Meta.Prefix);
@@ -293,6 +423,7 @@ internal sealed class Node10 : INode
return nn; return nn;
} }
/// <inheritdoc />
public INode? Shrink() public INode? Shrink()
{ {
if (Meta.Size > 4) return null; if (Meta.Size > 4) return null;
@@ -304,6 +435,7 @@ internal sealed class Node10 : INode
return nn; return nn;
} }
/// <inheritdoc />
public void Iter(Func<INode, bool> f) public void Iter(Func<INode, bool> f)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -312,6 +444,7 @@ internal sealed class Node10 : INode
} }
} }
/// <inheritdoc />
public INode?[] Children() public INode?[] Children()
{ {
var result = new INode?[Meta.Size]; var result = new INode?[Meta.Size];
@@ -319,6 +452,7 @@ internal sealed class Node10 : INode
return result; return result;
} }
/// <inheritdoc />
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts) public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix); => Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
} }
@@ -337,23 +471,35 @@ internal sealed class Node16 : INode
private readonly byte[] _key = new byte[16]; private readonly byte[] _key = new byte[16];
internal readonly NodeMeta Meta = new(); 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) public Node16(ReadOnlySpan<byte> prefix)
{ {
SetPrefix(prefix); SetPrefix(prefix);
} }
/// <inheritdoc />
public bool IsLeaf => false; public bool IsLeaf => false;
/// <inheritdoc />
public NodeMeta? Base => Meta; public NodeMeta? Base => Meta;
/// <inheritdoc />
public ushort NumChildren => Meta.Size; public ushort NumChildren => Meta.Size;
/// <inheritdoc />
public bool IsFull => Meta.Size >= 16; public bool IsFull => Meta.Size >= 16;
/// <inheritdoc />
public string Kind => "NODE16"; public string Kind => "NODE16";
/// <inheritdoc />
public byte[] Path() => Meta.Prefix; public byte[] Path() => Meta.Prefix;
/// <inheritdoc />
public void SetPrefix(ReadOnlySpan<byte> pre) public void SetPrefix(ReadOnlySpan<byte> pre)
{ {
Meta.Prefix = pre.ToArray(); Meta.Prefix = pre.ToArray();
} }
/// <inheritdoc />
public void AddChild(byte c, INode n) public void AddChild(byte c, INode n)
{ {
if (Meta.Size >= 16) throw new InvalidOperationException("node16 full!"); if (Meta.Size >= 16) throw new InvalidOperationException("node16 full!");
@@ -362,6 +508,7 @@ internal sealed class Node16 : INode
Meta.Size++; Meta.Size++;
} }
/// <inheritdoc />
public ChildRef? FindChild(byte c) public ChildRef? FindChild(byte c)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -375,6 +522,7 @@ internal sealed class Node16 : INode
return null; return null;
} }
/// <inheritdoc />
public void DeleteChild(byte c) public void DeleteChild(byte c)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -400,6 +548,7 @@ internal sealed class Node16 : INode
} }
} }
/// <inheritdoc />
public INode Grow() public INode Grow()
{ {
var nn = new Node48(Meta.Prefix); var nn = new Node48(Meta.Prefix);
@@ -410,6 +559,7 @@ internal sealed class Node16 : INode
return nn; return nn;
} }
/// <inheritdoc />
public INode? Shrink() public INode? Shrink()
{ {
if (Meta.Size > 10) return null; if (Meta.Size > 10) return null;
@@ -421,6 +571,7 @@ internal sealed class Node16 : INode
return nn; return nn;
} }
/// <inheritdoc />
public void Iter(Func<INode, bool> f) public void Iter(Func<INode, bool> f)
{ {
for (int i = 0; i < Meta.Size; i++) for (int i = 0; i < Meta.Size; i++)
@@ -429,6 +580,7 @@ internal sealed class Node16 : INode
} }
} }
/// <inheritdoc />
public INode?[] Children() public INode?[] Children()
{ {
var result = new INode?[Meta.Size]; var result = new INode?[Meta.Size];
@@ -436,6 +588,7 @@ internal sealed class Node16 : INode
return result; return result;
} }
/// <inheritdoc />
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts) public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix); => 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 byte[] Key = new byte[256]; // 1-indexed: 0 means no entry
internal readonly NodeMeta Meta = new(); 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) public Node48(ReadOnlySpan<byte> prefix)
{ {
SetPrefix(prefix); SetPrefix(prefix);
} }
/// <inheritdoc />
public bool IsLeaf => false; public bool IsLeaf => false;
/// <inheritdoc />
public NodeMeta? Base => Meta; public NodeMeta? Base => Meta;
/// <inheritdoc />
public ushort NumChildren => Meta.Size; public ushort NumChildren => Meta.Size;
/// <inheritdoc />
public bool IsFull => Meta.Size >= 48; public bool IsFull => Meta.Size >= 48;
/// <inheritdoc />
public string Kind => "NODE48"; public string Kind => "NODE48";
/// <inheritdoc />
public byte[] Path() => Meta.Prefix; public byte[] Path() => Meta.Prefix;
/// <inheritdoc />
public void SetPrefix(ReadOnlySpan<byte> pre) public void SetPrefix(ReadOnlySpan<byte> pre)
{ {
Meta.Prefix = pre.ToArray(); Meta.Prefix = pre.ToArray();
} }
/// <inheritdoc />
public void AddChild(byte c, INode n) public void AddChild(byte c, INode n)
{ {
if (Meta.Size >= 48) throw new InvalidOperationException("node48 full!"); if (Meta.Size >= 48) throw new InvalidOperationException("node48 full!");
@@ -479,6 +644,7 @@ internal sealed class Node48 : INode
Meta.Size++; Meta.Size++;
} }
/// <inheritdoc />
public ChildRef? FindChild(byte c) public ChildRef? FindChild(byte c)
{ {
var i = Key[c]; var i = Key[c];
@@ -487,6 +653,7 @@ internal sealed class Node48 : INode
return new ChildRef(() => Child[idx], v => Child[idx] = v); return new ChildRef(() => Child[idx], v => Child[idx] = v);
} }
/// <inheritdoc />
public void DeleteChild(byte c) public void DeleteChild(byte c)
{ {
var i = Key[c]; var i = Key[c];
@@ -510,6 +677,7 @@ internal sealed class Node48 : INode
Meta.Size--; Meta.Size--;
} }
/// <inheritdoc />
public INode Grow() public INode Grow()
{ {
var nn = new Node256(Meta.Prefix); var nn = new Node256(Meta.Prefix);
@@ -524,6 +692,7 @@ internal sealed class Node48 : INode
return nn; return nn;
} }
/// <inheritdoc />
public INode? Shrink() public INode? Shrink()
{ {
if (Meta.Size > 16) return null; if (Meta.Size > 16) return null;
@@ -539,6 +708,7 @@ internal sealed class Node48 : INode
return nn; return nn;
} }
/// <inheritdoc />
public void Iter(Func<INode, bool> f) public void Iter(Func<INode, bool> f)
{ {
foreach (var c in Child) foreach (var c in Child)
@@ -547,6 +717,7 @@ internal sealed class Node48 : INode
} }
} }
/// <inheritdoc />
public INode?[] Children() public INode?[] Children()
{ {
var result = new INode?[Meta.Size]; var result = new INode?[Meta.Size];
@@ -554,6 +725,7 @@ internal sealed class Node48 : INode
return result; return result;
} }
/// <inheritdoc />
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts) public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix); => Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
} }
@@ -571,35 +743,49 @@ internal sealed class Node256 : INode
internal readonly INode?[] Child = new INode?[256]; internal readonly INode?[] Child = new INode?[256];
internal readonly NodeMeta Meta = new(); 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) public Node256(ReadOnlySpan<byte> prefix)
{ {
SetPrefix(prefix); SetPrefix(prefix);
} }
/// <inheritdoc />
public bool IsLeaf => false; public bool IsLeaf => false;
/// <inheritdoc />
public NodeMeta? Base => Meta; public NodeMeta? Base => Meta;
/// <inheritdoc />
public ushort NumChildren => Meta.Size; public ushort NumChildren => Meta.Size;
/// <inheritdoc />
public bool IsFull => false; // node256 is never full public bool IsFull => false; // node256 is never full
/// <inheritdoc />
public string Kind => "NODE256"; public string Kind => "NODE256";
/// <inheritdoc />
public byte[] Path() => Meta.Prefix; public byte[] Path() => Meta.Prefix;
/// <inheritdoc />
public void SetPrefix(ReadOnlySpan<byte> pre) public void SetPrefix(ReadOnlySpan<byte> pre)
{ {
Meta.Prefix = pre.ToArray(); Meta.Prefix = pre.ToArray();
} }
/// <inheritdoc />
public void AddChild(byte c, INode n) public void AddChild(byte c, INode n)
{ {
Child[c] = n; Child[c] = n;
Meta.Size++; Meta.Size++;
} }
/// <inheritdoc />
public ChildRef? FindChild(byte c) public ChildRef? FindChild(byte c)
{ {
if (Child[c] == null) return null; if (Child[c] == null) return null;
return new ChildRef(() => Child[c], v => Child[c] = v); return new ChildRef(() => Child[c], v => Child[c] = v);
} }
/// <inheritdoc />
public void DeleteChild(byte c) public void DeleteChild(byte c)
{ {
if (Child[c] != null) 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"); public INode Grow() => throw new InvalidOperationException("grow can not be called on node256");
/// <inheritdoc />
public INode? Shrink() public INode? Shrink()
{ {
if (Meta.Size > 48) return null; if (Meta.Size > 48) return null;
@@ -625,6 +813,7 @@ internal sealed class Node256 : INode
return nn; return nn;
} }
/// <inheritdoc />
public void Iter(Func<INode, bool> f) public void Iter(Func<INode, bool> f)
{ {
for (int i = 0; i < 256; i++) for (int i = 0; i < 256; i++)
@@ -636,12 +825,14 @@ internal sealed class Node256 : INode
} }
} }
/// <inheritdoc />
public INode?[] Children() public INode?[] Children()
{ {
// Return the full 256 array, same as Go // Return the full 256 array, same as Go
return (INode?[])Child.Clone(); return (INode?[])Child.Clone();
} }
/// <inheritdoc />
public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts) public (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchParts(ReadOnlyMemory<byte>[] parts)
=> Parts.MatchPartsAgainstFragment(parts, Meta.Prefix); => Parts.MatchPartsAgainstFragment(parts, Meta.Prefix);
} }
@@ -34,6 +34,8 @@ public class SubjectTree<T>
/// Insert a value into the tree. Returns (oldValue, existed). /// Insert a value into the tree. Returns (oldValue, existed).
/// If the subject already existed, oldValue is the previous value and existed is true. /// If the subject already existed, oldValue is the previous value and existed is true.
/// </summary> /// </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) public (T? OldValue, bool Existed) Insert(ReadOnlySpan<byte> subject, T value)
{ {
// Make sure we never insert anything with a noPivot byte. // Make sure we never insert anything with a noPivot byte.
@@ -53,6 +55,7 @@ public class SubjectTree<T>
/// <summary> /// <summary>
/// Find the value for an exact subject match. /// Find the value for an exact subject match.
/// </summary> /// </summary>
/// <param name="subject">Literal subject to lookup.</param>
public (T? Value, bool Found) Find(ReadOnlySpan<byte> subject) public (T? Value, bool Found) Find(ReadOnlySpan<byte> subject)
{ {
int si = 0; int si = 0;
@@ -98,6 +101,7 @@ public class SubjectTree<T>
/// Delete the item for the given subject. /// Delete the item for the given subject.
/// Returns (deletedValue, wasFound). /// Returns (deletedValue, wasFound).
/// </summary> /// </summary>
/// <param name="subject">Literal subject to delete.</param>
public (T? Value, bool Found) Delete(ReadOnlySpan<byte> subject) public (T? Value, bool Found) Delete(ReadOnlySpan<byte> subject)
{ {
if (subject.Length == 0) if (subject.Length == 0)
@@ -116,6 +120,8 @@ public class SubjectTree<T>
/// <summary> /// <summary>
/// Match against a filter subject with wildcards and invoke the callback for each matched value. /// Match against a filter subject with wildcards and invoke the callback for each matched value.
/// </summary> /// </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) public void Match(ReadOnlySpan<byte> filter, Action<byte[], T>? callback)
{ {
if (Root == null || filter.Length == 0 || callback == null) if (Root == null || filter.Length == 0 || callback == null)
@@ -136,6 +142,8 @@ public class SubjectTree<T>
/// Returning false from the callback stops matching immediately. /// Returning false from the callback stops matching immediately.
/// Returns true if matching ran to completion, false if callback stopped it early. /// Returns true if matching ran to completion, false if callback stopped it early.
/// </summary> /// </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) public bool MatchUntil(ReadOnlySpan<byte> filter, Func<byte[], T, bool>? callback)
{ {
if (Root == null || filter.Length == 0 || callback == null) if (Root == null || filter.Length == 0 || callback == null)
@@ -150,6 +158,7 @@ public class SubjectTree<T>
/// <summary> /// <summary>
/// Walk all entries in lexicographic order. The callback can return false to terminate. /// Walk all entries in lexicographic order. The callback can return false to terminate.
/// </summary> /// </summary>
/// <param name="cb">Callback invoked in lexicographic subject order.</param>
public void IterOrdered(Func<byte[], T, bool> cb) public void IterOrdered(Func<byte[], T, bool> cb)
{ {
if (Root == null) return; if (Root == null) return;
@@ -159,6 +168,7 @@ public class SubjectTree<T>
/// <summary> /// <summary>
/// Walk all entries in no guaranteed order. The callback can return false to terminate. /// Walk all entries in no guaranteed order. The callback can return false to terminate.
/// </summary> /// </summary>
/// <param name="cb">Callback invoked for each entry.</param>
public void IterFast(Func<byte[], T, bool> cb) public void IterFast(Func<byte[], T, bool> cb)
{ {
if (Root == null) return; if (Root == null) return;
@@ -169,6 +179,7 @@ public class SubjectTree<T>
/// Dumps a human-readable representation of the tree. /// Dumps a human-readable representation of the tree.
/// Go reference: server/stree/dump.go /// Go reference: server/stree/dump.go
/// </summary> /// </summary>
/// <param name="writer">Text writer that receives dump output.</param>
public void Dump(TextWriter writer) public void Dump(TextWriter writer)
{ {
Dump(writer, Root, 0); Dump(writer, Root, 0);
@@ -433,6 +444,10 @@ public class SubjectTree<T>
/// Internal recursive match. /// Internal recursive match.
/// Go reference: server/stree/stree.go:match /// Go reference: server/stree/stree.go:match
/// </summary> /// </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) internal bool MatchInternal(INode? n, ReadOnlyMemory<byte>[] parts, byte[] pre, Func<byte[], T, bool> cb)
{ {
// Capture if we are sitting on a terminal fwc. // Capture if we are sitting on a terminal fwc.
@@ -562,6 +577,10 @@ public class SubjectTree<T>
/// Internal iter function to walk nodes. /// Internal iter function to walk nodes.
/// Go reference: server/stree/stree.go:iter /// Go reference: server/stree/stree.go:iter
/// </summary> /// </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) internal bool IterInternal(INode n, byte[] pre, bool ordered, Func<byte[], T, bool> cb)
{ {
if (n.IsLeaf) 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. /// Iterates the smaller of the two provided subject trees and looks for matching entries in the other.
/// Go reference: server/stree/stree.go:LazyIntersect /// Go reference: server/stree/stree.go:LazyIntersect
/// </summary> /// </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) 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) 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. /// The callback is invoked at most once per matching subject.
/// Go reference: server/stree/stree.go IntersectGSL /// Go reference: server/stree/stree.go IntersectGSL
/// </summary> /// </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>( public static void IntersectGSL<T, SL>(
SubjectTree<T>? tree, SubjectTree<T>? tree,
GenericSubjectList<SL>? sublist, GenericSubjectList<SL>? sublist,
@@ -32,6 +32,12 @@ public static class StreamApiHandlers
private const string SnapshotPrefix = JetStreamApiSubjects.StreamSnapshot; private const string SnapshotPrefix = JetStreamApiSubjects.StreamSnapshot;
private const string RestorePrefix = JetStreamApiSubjects.StreamRestore; 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) public static JetStreamApiResponse HandleCreate(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, CreatePrefix); var streamName = ExtractTrailingToken(subject, CreatePrefix);
@@ -48,6 +54,11 @@ public static class StreamApiHandlers
return streamManager.CreateOrUpdate(config); 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) public static JetStreamApiResponse HandleInfo(string subject, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, InfoPrefix); var streamName = ExtractTrailingToken(subject, InfoPrefix);
@@ -57,6 +68,12 @@ public static class StreamApiHandlers
return streamManager.GetInfo(streamName); 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) public static JetStreamApiResponse HandleUpdate(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, UpdatePrefix); var streamName = ExtractTrailingToken(subject, UpdatePrefix);
@@ -78,6 +95,11 @@ public static class StreamApiHandlers
return streamManager.CreateOrUpdate(config); 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) public static JetStreamApiResponse HandleDelete(string subject, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, DeletePrefix); var streamName = ExtractTrailingToken(subject, DeletePrefix);
@@ -93,6 +115,9 @@ public static class StreamApiHandlers
/// Handles stream purge with optional filter, seq, and keep options. /// Handles stream purge with optional filter, seq, and keep options.
/// Go reference: jetstream_api.go:1200-1350. /// Go reference: jetstream_api.go:1200-1350.
/// </summary> /// </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) public static JetStreamApiResponse HandlePurge(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, PurgePrefix); var streamName = ExtractTrailingToken(subject, PurgePrefix);
@@ -107,6 +132,11 @@ public static class StreamApiHandlers
return JetStreamApiResponse.PurgeResponse((ulong)purged); 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) public static JetStreamApiResponse HandleNames(ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var offset = ParseOffset(payload); 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) public static JetStreamApiResponse HandleList(ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var offset = ParseOffset(payload); var offset = ParseOffset(payload);
@@ -151,6 +186,12 @@ public static class StreamApiHandlers
return 0; 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) public static JetStreamApiResponse HandleMessageGet(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, MessageGetPrefix); 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) public static JetStreamApiResponse HandleMessageDelete(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, MessageDeletePrefix); var streamName = ExtractTrailingToken(subject, MessageDeletePrefix);
@@ -191,6 +238,11 @@ public static class StreamApiHandlers
: JetStreamApiResponse.NotFound(subject); : 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) public static JetStreamApiResponse HandleSnapshot(string subject, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, SnapshotPrefix); 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) public static JetStreamApiResponse HandleRestore(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
{ {
var streamName = ExtractTrailingToken(subject, RestorePrefix); var streamName = ExtractTrailingToken(subject, RestorePrefix);
@@ -230,6 +288,9 @@ public static class StreamApiHandlers
/// and enriches the response with stream name and chunk metadata. /// and enriches the response with stream name and chunk metadata.
/// Go reference: server/jetstream_api.go — jsStreamSnapshotT handler. /// Go reference: server/jetstream_api.go — jsStreamSnapshotT handler.
/// </summary> /// </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( public static async Task<JetStreamApiResponse> HandleSnapshotAsync(
string subject, string subject,
StreamManager streamManager, StreamManager streamManager,
@@ -264,6 +325,10 @@ public static class StreamApiHandlers
/// Async restore handler that validates the payload and returns a structured error on failure. /// Async restore handler that validates the payload and returns a structured error on failure.
/// Go reference: server/jetstream_api.go — jsStreamRestoreT handler. /// Go reference: server/jetstream_api.go — jsStreamRestoreT handler.
/// </summary> /// </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( public static async Task<JetStreamApiResponse> HandleRestoreAsync(
string subject, string subject,
byte[] payload, byte[] payload,
@@ -296,6 +361,10 @@ public static class StreamApiHandlers
/// <see cref="JetStreamMetaGroup.ProposeCreateStreamValidatedAsync"/>. /// <see cref="JetStreamMetaGroup.ProposeCreateStreamValidatedAsync"/>.
/// Go reference: jetstream_cluster.go:7620 jsClusteredStreamRequest. /// Go reference: jetstream_cluster.go:7620 jsClusteredStreamRequest.
/// </summary> /// </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( public static async Task<JetStreamApiResponse> HandleClusteredCreateAsync(
string subject, string subject,
byte[] payload, byte[] payload,
@@ -330,6 +399,10 @@ public static class StreamApiHandlers
/// Calls <see cref="JetStreamMetaGroup.ProcessUpdateStreamAssignment"/> after validating leadership. /// Calls <see cref="JetStreamMetaGroup.ProcessUpdateStreamAssignment"/> after validating leadership.
/// Go reference: jetstream_cluster.go jsClusteredStreamUpdateRequest. /// Go reference: jetstream_cluster.go jsClusteredStreamUpdateRequest.
/// </summary> /// </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( public static async Task<JetStreamApiResponse> HandleClusteredUpdateAsync(
string subject, string subject,
byte[] payload, byte[] payload,
@@ -371,6 +444,9 @@ public static class StreamApiHandlers
/// Calls <see cref="JetStreamMetaGroup.ProposeDeleteStreamValidatedAsync"/> after validating leadership. /// Calls <see cref="JetStreamMetaGroup.ProposeDeleteStreamValidatedAsync"/> after validating leadership.
/// Go reference: jetstream_cluster.go jsClusteredStreamDeleteRequest. /// Go reference: jetstream_cluster.go jsClusteredStreamDeleteRequest.
/// </summary> /// </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( public static async Task<JetStreamApiResponse> HandleClusteredDeleteAsync(
string subject, string subject,
JetStreamMetaGroup metaGroup, JetStreamMetaGroup metaGroup,
@@ -408,6 +484,10 @@ public static class StreamApiHandlers
return token.Length == 0 ? null : token; 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) internal static PurgeRequest ParsePurgeRequest(ReadOnlySpan<byte> payload)
{ {
if (payload.IsEmpty) if (payload.IsEmpty)
@@ -8,10 +8,29 @@ namespace NATS.Server.JetStream.Cluster;
/// </summary> /// </summary>
public sealed class RaftGroup public sealed class RaftGroup
{ {
/// <summary>
/// Gets or sets raft group name.
/// </summary>
public required string Name { get; init; } public required string Name { get; init; }
/// <summary>
/// Gets or sets peer IDs currently assigned to the group.
/// </summary>
public List<string> Peers { get; init; } = []; public List<string> Peers { get; init; } = [];
/// <summary>
/// Gets or sets storage type used by group replicas.
/// </summary>
public string StorageType { get; set; } = "file"; 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; public string Cluster { get; set; } = string.Empty;
/// <summary>
/// Gets or sets preferred leader peer ID.
/// </summary>
public string Preferred { get; set; } = string.Empty; public string Preferred { get; set; } = string.Empty;
/// <summary> /// <summary>
@@ -26,7 +45,15 @@ public sealed class RaftGroup
/// </summary> /// </summary>
public bool HasDesiredReplicas => DesiredReplicas > 0; public bool HasDesiredReplicas => DesiredReplicas > 0;
/// <summary>
/// Gets the minimum acknowledgements required for quorum.
/// </summary>
public int QuorumSize => (Peers.Count / 2) + 1; 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; public bool HasQuorum(int ackCount) => ackCount >= QuorumSize;
/// <summary> /// <summary>
@@ -45,6 +72,7 @@ public sealed class RaftGroup
/// Returns true if the given peerId is a member of this group (case-sensitive). /// Returns true if the given peerId is a member of this group (case-sensitive).
/// Go reference: jetstream_cluster.go isMember helper. /// Go reference: jetstream_cluster.go isMember helper.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier to check.</param>
public bool IsMember(string peerId) => Peers.Contains(peerId, StringComparer.Ordinal); public bool IsMember(string peerId) => Peers.Contains(peerId, StringComparer.Ordinal);
/// <summary> /// <summary>
@@ -52,6 +80,7 @@ public sealed class RaftGroup
/// Throws <see cref="InvalidOperationException"/> if peerId is not a member. /// Throws <see cref="InvalidOperationException"/> if peerId is not a member.
/// Go reference: jetstream_cluster.go setPreferred / rg.Preferred assignment. /// Go reference: jetstream_cluster.go setPreferred / rg.Preferred assignment.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier to set as preferred leader.</param>
public void SetPreferred(string peerId) public void SetPreferred(string peerId)
{ {
if (!IsMember(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. /// <see cref="Preferred"/> is cleared. Returns true if the peer was found and removed.
/// Go reference: jetstream_cluster.go removePeer. /// Go reference: jetstream_cluster.go removePeer.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier to remove.</param>
public bool RemovePeer(string peerId) public bool RemovePeer(string peerId)
{ {
var removed = Peers.Remove(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. /// Adds a peer to the group if not already present. Returns true if the peer was added.
/// Go reference: jetstream_cluster.go addPeer / expandGroup. /// Go reference: jetstream_cluster.go addPeer / expandGroup.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier to add.</param>
public bool AddPeer(string peerId) public bool AddPeer(string peerId)
{ {
if (IsMember(peerId)) if (IsMember(peerId))
@@ -92,6 +123,10 @@ public sealed class RaftGroup
/// Go reference: jetstream_cluster.go createGroupForStream — calls selectPeerGroup then /// Go reference: jetstream_cluster.go createGroupForStream — calls selectPeerGroup then
/// assigns rg.DesiredReplicas = replicas. /// assigns rg.DesiredReplicas = replicas.
/// </summary> /// </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( public static RaftGroup CreateRaftGroup(
string groupName, string groupName,
int replicas, int replicas,
@@ -110,13 +145,44 @@ public sealed class RaftGroup
/// </summary> /// </summary>
public sealed class StreamAssignment public sealed class StreamAssignment
{ {
/// <summary>
/// Gets or sets stream name for this assignment.
/// </summary>
public required string StreamName { get; init; } public required string StreamName { get; init; }
/// <summary>
/// Gets or sets raft group owning stream replicas.
/// </summary>
public required RaftGroup Group { get; init; } public required RaftGroup Group { get; init; }
/// <summary>
/// Gets or sets stream assignment creation time.
/// </summary>
public DateTime Created { get; init; } = DateTime.UtcNow; public DateTime Created { get; init; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets serialized stream config snapshot.
/// </summary>
public string ConfigJson { get; set; } = "{}"; public string ConfigJson { get; set; } = "{}";
/// <summary>
/// Gets or sets synchronization subject used by assignment workflows.
/// </summary>
public string SyncSubject { get; set; } = string.Empty; public string SyncSubject { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether assignment response has been observed.
/// </summary>
public bool Responded { get; set; } public bool Responded { get; set; }
/// <summary>
/// Gets or sets whether stream is recovering.
/// </summary>
public bool Recovering { get; set; } public bool Recovering { get; set; }
/// <summary>
/// Gets or sets whether stream is currently being reassigned.
/// </summary>
public bool Reassigning { get; set; } public bool Reassigning { get; set; }
/// <summary> /// <summary>
@@ -144,12 +210,39 @@ public sealed class StreamAssignment
/// </summary> /// </summary>
public sealed class ConsumerAssignment public sealed class ConsumerAssignment
{ {
/// <summary>
/// Gets or sets consumer name.
/// </summary>
public required string ConsumerName { get; init; } public required string ConsumerName { get; init; }
/// <summary>
/// Gets or sets parent stream name.
/// </summary>
public required string StreamName { get; init; } public required string StreamName { get; init; }
/// <summary>
/// Gets or sets raft group owning consumer state.
/// </summary>
public required RaftGroup Group { get; init; } public required RaftGroup Group { get; init; }
/// <summary>
/// Gets or sets consumer assignment creation time.
/// </summary>
public DateTime Created { get; init; } = DateTime.UtcNow; public DateTime Created { get; init; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets serialized consumer config snapshot.
/// </summary>
public string ConfigJson { get; set; } = "{}"; public string ConfigJson { get; set; } = "{}";
/// <summary>
/// Gets or sets whether assignment response has been observed.
/// </summary>
public bool Responded { get; set; } public bool Responded { get; set; }
/// <summary>
/// Gets or sets whether consumer is recovering.
/// </summary>
public bool Recovering { get; set; } public bool Recovering { get; set; }
/// <summary> /// <summary>
@@ -43,11 +43,20 @@ public sealed class JetStreamMetaGroup
// Go reference: jetstream_cluster.go — forward-compatibility skip counter. // Go reference: jetstream_cluster.go — forward-compatibility skip counter.
private int _skippedUnsupportedEntries; 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) public JetStreamMetaGroup(int nodes)
: this(nodes, selfIndex: 1) : 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) public JetStreamMetaGroup(int nodes, int selfIndex)
{ {
_nodes = nodes; _nodes = nodes;
@@ -117,6 +126,8 @@ public sealed class JetStreamMetaGroup
/// Increments OpsCount on duplicate proposals for the same stream name. /// Increments OpsCount on duplicate proposals for the same stream name.
/// Go reference: jetstream_cluster.go inflight proposal tracking. /// Go reference: jetstream_cluster.go inflight proposal tracking.
/// </summary> /// </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) public void TrackInflightStreamProposal(string account, StreamAssignment sa)
{ {
var accountDict = _inflightStreams.GetOrAdd(account, _ => new Dictionary<string, InflightInfo>(StringComparer.Ordinal)); 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. /// Removes the account entry when its dictionary becomes empty.
/// Go reference: jetstream_cluster.go inflight proposal tracking. /// Go reference: jetstream_cluster.go inflight proposal tracking.
/// </summary> /// </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) public void RemoveInflightStreamProposal(string account, string streamName)
{ {
if (!_inflightStreams.TryGetValue(account, out var accountDict)) 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. /// Returns true if the given stream is currently tracked as inflight for the account.
/// Go reference: jetstream_cluster.go inflight check. /// Go reference: jetstream_cluster.go inflight check.
/// </summary> /// </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) public bool IsStreamInflight(string account, string streamName)
{ {
if (!_inflightStreams.TryGetValue(account, out var accountDict)) 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. /// Increments OpsCount on duplicate proposals for the same stream/consumer key.
/// Go reference: jetstream_cluster.go inflight consumer proposal tracking. /// Go reference: jetstream_cluster.go inflight consumer proposal tracking.
/// </summary> /// </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) public void TrackInflightConsumerProposal(string account, string streamName, string consumerName, ConsumerAssignment? ca = null)
{ {
var key = $"{streamName}/{consumerName}"; var key = $"{streamName}/{consumerName}";
@@ -195,6 +214,9 @@ public sealed class JetStreamMetaGroup
/// Removes the account entry when its dictionary becomes empty. /// Removes the account entry when its dictionary becomes empty.
/// Go reference: jetstream_cluster.go inflight consumer proposal tracking. /// Go reference: jetstream_cluster.go inflight consumer proposal tracking.
/// </summary> /// </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) public void RemoveInflightConsumerProposal(string account, string streamName, string consumerName)
{ {
var key = $"{streamName}/{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. /// Returns true if the given consumer is currently tracked as inflight for the account.
/// Go reference: jetstream_cluster.go inflight check. /// Go reference: jetstream_cluster.go inflight check.
/// </summary> /// </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) public bool IsConsumerInflight(string account, string streamName, string consumerName)
{ {
var key = $"{streamName}/{consumerName}"; var key = $"{streamName}/{consumerName}";
@@ -254,6 +279,8 @@ public sealed class JetStreamMetaGroup
/// and the full assignment map. /// and the full assignment map.
/// Go reference: jetstream_cluster.go processStreamAssignment. /// Go reference: jetstream_cluster.go processStreamAssignment.
/// </summary> /// </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) public Task ProposeCreateStreamAsync(StreamConfig config, CancellationToken ct)
=> ProposeCreateStreamAsync(config, group: null, ct); => ProposeCreateStreamAsync(config, group: null, ct);
@@ -262,6 +289,9 @@ public sealed class JetStreamMetaGroup
/// Idempotent: duplicate creates for the same name are silently ignored. /// Idempotent: duplicate creates for the same name are silently ignored.
/// Go reference: jetstream_cluster.go processStreamAssignment. /// Go reference: jetstream_cluster.go processStreamAssignment.
/// </summary> /// </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) public Task ProposeCreateStreamAsync(StreamConfig config, RaftGroup? group, CancellationToken ct)
{ {
_ = ct; _ = ct;
@@ -285,6 +315,9 @@ public sealed class JetStreamMetaGroup
/// Use this method when the caller needs strict validation (e.g. API layer). /// Use this method when the caller needs strict validation (e.g. API layer).
/// Go reference: jetstream_cluster.go processStreamAssignment with validation. /// Go reference: jetstream_cluster.go processStreamAssignment with validation.
/// </summary> /// </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) public Task ProposeCreateStreamValidatedAsync(StreamConfig config, RaftGroup? group, CancellationToken ct)
{ {
_ = ct; _ = ct;
@@ -313,6 +346,8 @@ public sealed class JetStreamMetaGroup
/// Proposes deleting a stream. Removes from both tracking structures. /// Proposes deleting a stream. Removes from both tracking structures.
/// Go reference: jetstream_cluster.go processStreamDelete. /// Go reference: jetstream_cluster.go processStreamDelete.
/// </summary> /// </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) public Task ProposeDeleteStreamAsync(string streamName, CancellationToken ct)
{ {
_ = ct; _ = ct;
@@ -324,6 +359,8 @@ public sealed class JetStreamMetaGroup
/// Proposes deleting a stream with leader validation. /// Proposes deleting a stream with leader validation.
/// Go reference: jetstream_cluster.go processStreamDelete with leader check. /// Go reference: jetstream_cluster.go processStreamDelete with leader check.
/// </summary> /// </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) public Task ProposeDeleteStreamValidatedAsync(string streamName, CancellationToken ct)
{ {
_ = ct; _ = ct;
@@ -344,6 +381,10 @@ public sealed class JetStreamMetaGroup
/// If the stream does not exist, the consumer is silently not tracked. /// If the stream does not exist, the consumer is silently not tracked.
/// Go reference: jetstream_cluster.go processConsumerAssignment. /// Go reference: jetstream_cluster.go processConsumerAssignment.
/// </summary> /// </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( public Task ProposeCreateConsumerAsync(
string streamName, string streamName,
string consumerName, string consumerName,
@@ -369,6 +410,10 @@ public sealed class JetStreamMetaGroup
/// Use this method when the caller needs strict validation (e.g. API layer). /// Use this method when the caller needs strict validation (e.g. API layer).
/// Go reference: jetstream_cluster.go processConsumerAssignment with validation. /// Go reference: jetstream_cluster.go processConsumerAssignment with validation.
/// </summary> /// </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( public Task ProposeCreateConsumerValidatedAsync(
string streamName, string streamName,
string consumerName, string consumerName,
@@ -400,6 +445,9 @@ public sealed class JetStreamMetaGroup
/// Silently does nothing if stream or consumer does not exist. /// Silently does nothing if stream or consumer does not exist.
/// Go reference: jetstream_cluster.go processConsumerDelete. /// Go reference: jetstream_cluster.go processConsumerDelete.
/// </summary> /// </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( public Task ProposeDeleteConsumerAsync(
string streamName, string streamName,
string consumerName, string consumerName,
@@ -414,6 +462,9 @@ public sealed class JetStreamMetaGroup
/// Proposes deleting a consumer with leader validation. /// Proposes deleting a consumer with leader validation.
/// Go reference: jetstream_cluster.go processConsumerDelete with leader check. /// Go reference: jetstream_cluster.go processConsumerDelete with leader check.
/// </summary> /// </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( public Task ProposeDeleteConsumerValidatedAsync(
string streamName, string streamName,
string consumerName, string consumerName,
@@ -441,6 +492,7 @@ public sealed class JetStreamMetaGroup
/// Idempotent: duplicate assignments for the same stream name are accepted. /// Idempotent: duplicate assignments for the same stream name are accepted.
/// Go reference: jetstream_cluster.go:4541 processStreamAssignment. /// Go reference: jetstream_cluster.go:4541 processStreamAssignment.
/// </summary> /// </summary>
/// <param name="sa">Stream assignment entry received from replicated meta log.</param>
public bool ProcessStreamAssignment(StreamAssignment sa) public bool ProcessStreamAssignment(StreamAssignment sa)
{ {
if (string.IsNullOrEmpty(sa.StreamName) || sa.Group == null) if (string.IsNullOrEmpty(sa.StreamName) || sa.Group == null)
@@ -464,6 +516,7 @@ public sealed class JetStreamMetaGroup
/// Returns false if the stream does not exist. /// Returns false if the stream does not exist.
/// Go reference: jetstream_cluster.go processUpdateStreamAssignment. /// Go reference: jetstream_cluster.go processUpdateStreamAssignment.
/// </summary> /// </summary>
/// <param name="sa">Updated stream assignment payload.</param>
public bool ProcessUpdateStreamAssignment(StreamAssignment sa) public bool ProcessUpdateStreamAssignment(StreamAssignment sa)
{ {
if (!_assignments.TryGetValue(sa.StreamName, out var existing)) 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. /// Returns false if stream didn't exist. Returns true if removed.
/// Go reference: jetstream_cluster.go processStreamRemoval. /// Go reference: jetstream_cluster.go processStreamRemoval.
/// </summary> /// </summary>
/// <param name="streamName">Stream name to remove from assignment state.</param>
public bool ProcessStreamRemoval(string streamName) public bool ProcessStreamRemoval(string streamName)
{ {
if (!_assignments.ContainsKey(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. /// Version 0 is treated as version 1 for backward compatibility with pre-versioned entries.
/// Go reference: jetstream_cluster.go:5300 processConsumerAssignment. /// Go reference: jetstream_cluster.go:5300 processConsumerAssignment.
/// </summary> /// </summary>
/// <param name="ca">Consumer assignment entry received from replicated meta log.</param>
public bool ProcessConsumerAssignment(ConsumerAssignment ca) public bool ProcessConsumerAssignment(ConsumerAssignment ca)
{ {
if (string.IsNullOrEmpty(ca.ConsumerName) || string.IsNullOrEmpty(ca.StreamName)) 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. /// Returns false if stream or consumer doesn't exist.
/// Go reference: jetstream_cluster.go processConsumerRemoval. /// Go reference: jetstream_cluster.go processConsumerRemoval.
/// </summary> /// </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) public bool ProcessConsumerRemoval(string streamName, string consumerName)
{ {
if (!_assignments.TryGetValue(streamName, out var sa)) 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. /// Directly adds a stream assignment to the meta-group state.
/// Used by the cluster monitor when processing RAFT entries. /// Used by the cluster monitor when processing RAFT entries.
/// </summary> /// </summary>
/// <param name="sa">Stream assignment to add/update.</param>
public void AddStreamAssignment(StreamAssignment sa) public void AddStreamAssignment(StreamAssignment sa)
{ {
_streams[sa.StreamName] = 0; _streams[sa.StreamName] = 0;
@@ -564,6 +622,7 @@ public sealed class JetStreamMetaGroup
/// Removes a stream assignment from the meta-group state. /// Removes a stream assignment from the meta-group state.
/// Used by the cluster monitor when processing RAFT entries. /// Used by the cluster monitor when processing RAFT entries.
/// </summary> /// </summary>
/// <param name="streamName">Stream name to remove.</param>
public void RemoveStreamAssignment(string streamName) public void RemoveStreamAssignment(string streamName)
{ {
ApplyStreamDelete(streamName); ApplyStreamDelete(streamName);
@@ -573,6 +632,8 @@ public sealed class JetStreamMetaGroup
/// Adds a consumer assignment to a stream's assignment. /// Adds a consumer assignment to a stream's assignment.
/// Increments the total consumer count if the consumer is new. /// Increments the total consumer count if the consumer is new.
/// </summary> /// </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) public void AddConsumerAssignment(string streamName, ConsumerAssignment ca)
{ {
if (_assignments.TryGetValue(streamName, out var sa)) if (_assignments.TryGetValue(streamName, out var sa))
@@ -587,6 +648,8 @@ public sealed class JetStreamMetaGroup
/// <summary> /// <summary>
/// Removes a consumer assignment from a stream. /// Removes a consumer assignment from a stream.
/// </summary> /// </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) public void RemoveConsumerAssignment(string streamName, string consumerName)
{ {
ApplyConsumerDelete(streamName, consumerName); ApplyConsumerDelete(streamName, consumerName);
@@ -596,6 +659,7 @@ public sealed class JetStreamMetaGroup
/// Replaces all assignments atomically (used for snapshot apply). /// Replaces all assignments atomically (used for snapshot apply).
/// Go reference: jetstream_cluster.go meta snapshot restore. /// Go reference: jetstream_cluster.go meta snapshot restore.
/// </summary> /// </summary>
/// <param name="newState">Complete replacement assignment map from snapshot state.</param>
public void ReplaceAllAssignments(Dictionary<string, StreamAssignment> newState) public void ReplaceAllAssignments(Dictionary<string, StreamAssignment> newState)
{ {
_assignments.Clear(); _assignments.Clear();
@@ -620,6 +684,10 @@ public sealed class JetStreamMetaGroup
/// Dispatches based on entry type prefix. /// Dispatches based on entry type prefix.
/// Go reference: jetstream_cluster.go processStreamAssignment / processConsumerAssignment. /// Go reference: jetstream_cluster.go processStreamAssignment / processConsumerAssignment.
/// </summary> /// </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) public void ApplyEntry(MetaEntryType entryType, string name, string? streamName = null, RaftGroup? group = null)
{ {
switch (entryType) switch (entryType)
@@ -665,6 +733,7 @@ public sealed class JetStreamMetaGroup
/// Returns the StreamAssignment for the given stream name, or null if not found. /// Returns the StreamAssignment for the given stream name, or null if not found.
/// Go reference: jetstream_cluster.go streamAssignment lookup in meta leader. /// Go reference: jetstream_cluster.go streamAssignment lookup in meta leader.
/// </summary> /// </summary>
/// <param name="streamName">Stream name to resolve.</param>
public StreamAssignment? GetStreamAssignment(string streamName) public StreamAssignment? GetStreamAssignment(string streamName)
=> _assignments.TryGetValue(streamName, out var assignment) ? assignment : null; => _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. /// Returns the ConsumerAssignment for the given stream and consumer, or null if not found.
/// Go reference: jetstream_cluster.go consumerAssignment lookup. /// Go reference: jetstream_cluster.go consumerAssignment lookup.
/// </summary> /// </summary>
/// <param name="streamName">Parent stream name.</param>
/// <param name="consumerName">Consumer name within the stream.</param>
public ConsumerAssignment? GetConsumerAssignment(string streamName, string consumerName) public ConsumerAssignment? GetConsumerAssignment(string streamName, string consumerName)
{ {
if (_assignments.TryGetValue(streamName, out var sa) if (_assignments.TryGetValue(streamName, out var sa)
@@ -694,6 +765,9 @@ public sealed class JetStreamMetaGroup
// State // State
// --------------------------------------------------------------- // ---------------------------------------------------------------
/// <summary>
/// Returns a point-in-time snapshot of meta-group topology and assignment counts.
/// </summary>
public MetaGroupState GetState() public MetaGroupState GetState()
{ {
return new MetaGroupState return new MetaGroupState
@@ -719,6 +793,7 @@ public sealed class JetStreamMetaGroup
/// When becoming leader: fires OnLeaderChange event. /// When becoming leader: fires OnLeaderChange event.
/// Go reference: jetstream_cluster.go:7001-7074 processLeaderChange. /// Go reference: jetstream_cluster.go:7001-7074 processLeaderChange.
/// </summary> /// </summary>
/// <param name="isLeader">`true` when this node became leader; `false` when stepping down.</param>
public void ProcessLeaderChange(bool isLeader) public void ProcessLeaderChange(bool isLeader)
{ {
if (!isLeader) if (!isLeader)
@@ -756,6 +831,7 @@ public sealed class JetStreamMetaGroup
/// Registers a peer as known to this meta-group. /// Registers a peer as known to this meta-group.
/// Go reference: jetstream_cluster.go peer tracking in jetStreamCluster. /// Go reference: jetstream_cluster.go peer tracking in jetStreamCluster.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier to register.</param>
public void AddKnownPeer(string peerId) public void AddKnownPeer(string peerId)
{ {
lock (_knownPeers) lock (_knownPeers)
@@ -766,6 +842,7 @@ public sealed class JetStreamMetaGroup
/// Removes a peer from the known-peers set. /// Removes a peer from the known-peers set.
/// Go reference: jetstream_cluster.go peer removal tracking. /// Go reference: jetstream_cluster.go peer removal tracking.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier to remove.</param>
public void RemoveKnownPeer(string peerId) public void RemoveKnownPeer(string peerId)
{ {
lock (_knownPeers) lock (_knownPeers)
@@ -787,6 +864,7 @@ public sealed class JetStreamMetaGroup
/// and adds the new peer to their RaftGroup, triggering re-replication. /// and adds the new peer to their RaftGroup, triggering re-replication.
/// Go reference: jetstream_cluster.go:2290 processAddPeer. /// Go reference: jetstream_cluster.go:2290 processAddPeer.
/// </summary> /// </summary>
/// <param name="peerId">New peer identifier that joined the cluster.</param>
public void ProcessAddPeer(string peerId) public void ProcessAddPeer(string peerId)
{ {
// Always register the new peer. // Always register the new peer.
@@ -824,6 +902,7 @@ public sealed class JetStreamMetaGroup
/// triggers reassignment away from that peer. /// triggers reassignment away from that peer.
/// Go reference: jetstream_cluster.go:2342 processRemovePeer. /// Go reference: jetstream_cluster.go:2342 processRemovePeer.
/// </summary> /// </summary>
/// <param name="peerId">Peer identifier removed from the cluster.</param>
public void ProcessRemovePeer(string peerId) public void ProcessRemovePeer(string peerId)
{ {
// Always remove from known set. // 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. /// Returns true if a replacement peer was found; false if the peer list was merely shrunk.
/// Go reference: jetstream_cluster.go:2403 removePeerFromStreamLocked. /// Go reference: jetstream_cluster.go:2403 removePeerFromStreamLocked.
/// </summary> /// </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) public bool RemovePeerFromStream(string streamName, string peerId)
{ {
if (!_assignments.TryGetValue(streamName, out var sa)) 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. /// Returns true when a replacement peer was placed; false if the group was merely shrunk.
/// Go reference: jetstream_cluster.go:7077 remapStreamAssignment. /// Go reference: jetstream_cluster.go:7077 remapStreamAssignment.
/// </summary> /// </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) public bool RemapStreamAssignment(StreamAssignment assignment, IReadOnlyList<string> availablePeers, string removePeer)
{ {
var group = assignment.Group; var group = assignment.Group;
@@ -991,9 +1075,24 @@ public enum MetaEntryType
public sealed class MetaGroupState public sealed class MetaGroupState
{ {
/// <summary>
/// Gets stream names currently tracked by the meta group.
/// </summary>
public IReadOnlyList<string> Streams { get; init; } = []; public IReadOnlyList<string> Streams { get; init; } = [];
/// <summary>
/// Gets configured cluster size for this meta group model.
/// </summary>
public int ClusterSize { get; init; } public int ClusterSize { get; init; }
/// <summary>
/// Gets current leader identifier string.
/// </summary>
public string LeaderId { get; init; } = string.Empty; public string LeaderId { get; init; } = string.Empty;
/// <summary>
/// Gets leadership epoch/version used to detect leader transitions.
/// </summary>
public long LeadershipVersion { get; init; } public long LeadershipVersion { get; init; }
/// <summary> /// <summary>
@@ -20,8 +20,19 @@ public sealed class StreamReplicaGroup
// Last consumer op applied (used for diagnostics / unknown-op logging). // Last consumer op applied (used for diagnostics / unknown-op logging).
private string _lastUnknownCommand = string.Empty; private string _lastUnknownCommand = string.Empty;
/// <summary>
/// Gets stream name owned by this replica group.
/// </summary>
public string StreamName { get; } public string StreamName { get; }
/// <summary>
/// Gets raft nodes participating in this replica group.
/// </summary>
public IReadOnlyList<RaftNode> Nodes => _nodes; public IReadOnlyList<RaftNode> Nodes => _nodes;
/// <summary>
/// Gets current leader node for this group.
/// </summary>
public RaftNode Leader { get; private set; } public RaftNode Leader { get; private set; }
/// <summary> /// <summary>
@@ -85,6 +96,11 @@ public sealed class StreamReplicaGroup
/// <summary>Number of committed entries awaiting state-machine application.</summary> /// <summary>Number of committed entries awaiting state-machine application.</summary>
public int PendingCommits => Leader.CommitQueue.Count; 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) public StreamReplicaGroup(string streamName, int replicas)
{ {
StreamName = streamName; StreamName = streamName;
@@ -106,6 +122,7 @@ public sealed class StreamReplicaGroup
/// Go reference: jetstream_cluster.go processStreamAssignment — creates a per-stream /// Go reference: jetstream_cluster.go processStreamAssignment — creates a per-stream
/// raft group from the assignment's group peers. /// raft group from the assignment's group peers.
/// </summary> /// </summary>
/// <param name="assignment">Stream assignment containing peer layout and stream identity.</param>
public StreamReplicaGroup(StreamAssignment assignment) public StreamReplicaGroup(StreamAssignment assignment)
{ {
Assignment = assignment; Assignment = assignment;
@@ -130,6 +147,11 @@ public sealed class StreamReplicaGroup
Leader = ElectLeader(_nodes[0]); 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) public async ValueTask<long> ProposeAsync(string command, CancellationToken ct)
{ {
if (!Leader.IsLeader) if (!Leader.IsLeader)
@@ -143,6 +165,10 @@ public sealed class StreamReplicaGroup
/// Encodes subject + payload into a RAFT log entry command. /// Encodes subject + payload into a RAFT log entry command.
/// Go reference: jetstream_cluster.go processStreamMsg. /// Go reference: jetstream_cluster.go processStreamMsg.
/// </summary> /// </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( public async ValueTask<long> ProposeMessageAsync(
string subject, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct) string subject, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
{ {
@@ -159,6 +185,10 @@ public sealed class StreamReplicaGroup
return index; 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) public Task StepDownAsync(CancellationToken ct)
{ {
_ = 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) public Task ApplyPlacementAsync(IReadOnlyList<int> placement, CancellationToken ct)
{ {
_ = ct; _ = ct;
@@ -225,6 +260,7 @@ public sealed class StreamReplicaGroup
/// anything else — marks the entry as processed via MarkProcessed /// anything else — marks the entry as processed via MarkProcessed
/// Go reference: jetstream_cluster.go:processStreamEntries (apply loop). /// Go reference: jetstream_cluster.go:processStreamEntries (apply loop).
/// </summary> /// </summary>
/// <param name="ct">Cancellation token for async peer proposal operations.</param>
public async Task ApplyCommittedEntriesAsync(CancellationToken ct) public async Task ApplyCommittedEntriesAsync(CancellationToken ct)
{ {
while (Leader.CommitQueue.TryDequeue(out var entry)) 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. /// Applies a stream-level message operation (Store, Remove, Purge) to the local state.
/// Go reference: jetstream_cluster.go:2474-4261 processStreamEntries — per-message ops. /// Go reference: jetstream_cluster.go:2474-4261 processStreamEntries — per-message ops.
/// </summary> /// </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) public void ApplyStreamMsgOp(StreamMsgOp op, long index = 0)
{ {
switch (op) switch (op)
@@ -305,6 +343,7 @@ public sealed class StreamReplicaGroup
/// Applies a consumer state entry (Ack, Nak, Deliver, Term, Progress). /// Applies a consumer state entry (Ack, Nak, Deliver, Term, Progress).
/// Go reference: jetstream_cluster.go processConsumerEntries. /// Go reference: jetstream_cluster.go processConsumerEntries.
/// </summary> /// </summary>
/// <param name="op">Consumer operation to apply.</param>
public void ApplyConsumerEntry(ConsumerOp op) public void ApplyConsumerEntry(ConsumerOp op)
{ {
switch (op) switch (op)
@@ -356,6 +395,7 @@ public sealed class StreamReplicaGroup
/// the log up to that point. /// the log up to that point.
/// Go reference: raft.go CreateSnapshotCheckpoint. /// Go reference: raft.go CreateSnapshotCheckpoint.
/// </summary> /// </summary>
/// <param name="ct">Cancellation token for checkpoint operation.</param>
public Task<RaftSnapshot> CheckpointAsync(CancellationToken ct) public Task<RaftSnapshot> CheckpointAsync(CancellationToken ct)
=> Leader.CreateSnapshotCheckpointAsync(ct); => Leader.CreateSnapshotCheckpointAsync(ct);
@@ -364,6 +404,8 @@ public sealed class StreamReplicaGroup
/// commit-queue entries before applying the snapshot state. /// commit-queue entries before applying the snapshot state.
/// Go reference: raft.go DrainAndReplaySnapshot. /// Go reference: raft.go DrainAndReplaySnapshot.
/// </summary> /// </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) public Task RestoreFromSnapshotAsync(RaftSnapshot snapshot, CancellationToken ct)
=> Leader.DrainAndReplaySnapshotAsync(snapshot, ct); => Leader.DrainAndReplaySnapshotAsync(snapshot, ct);
@@ -416,13 +458,44 @@ public sealed class StreamReplicaGroup
/// </summary> /// </summary>
public sealed class StreamReplicaStatus public sealed class StreamReplicaStatus
{ {
/// <summary>
/// Gets stream name for this status snapshot.
/// </summary>
public string StreamName { get; init; } = string.Empty; public string StreamName { get; init; } = string.Empty;
/// <summary>
/// Gets leader node identifier.
/// </summary>
public string LeaderId { get; init; } = string.Empty; public string LeaderId { get; init; } = string.Empty;
/// <summary>
/// Gets current leader term.
/// </summary>
public int LeaderTerm { get; init; } public int LeaderTerm { get; init; }
/// <summary>
/// Gets applied message count.
/// </summary>
public long MessageCount { get; init; } public long MessageCount { get; init; }
/// <summary>
/// Gets last applied sequence value.
/// </summary>
public long LastSequence { get; init; } public long LastSequence { get; init; }
/// <summary>
/// Gets replica count in the group.
/// </summary>
public int ReplicaCount { get; init; } public int ReplicaCount { get; init; }
/// <summary>
/// Gets committed raft index.
/// </summary>
public long CommitIndex { get; init; } public long CommitIndex { get; init; }
/// <summary>
/// Gets applied raft index.
/// </summary>
public long AppliedIndex { get; init; } public long AppliedIndex { get; init; }
} }
@@ -431,8 +504,19 @@ public sealed class StreamReplicaStatus
/// </summary> /// </summary>
public sealed class LeaderChangedEventArgs(string previousLeaderId, string newLeaderId, int newTerm) : EventArgs public sealed class LeaderChangedEventArgs(string previousLeaderId, string newLeaderId, int newTerm) : EventArgs
{ {
/// <summary>
/// Gets previous leader identifier.
/// </summary>
public string PreviousLeaderId { get; } = previousLeaderId; public string PreviousLeaderId { get; } = previousLeaderId;
/// <summary>
/// Gets new leader identifier.
/// </summary>
public string NewLeaderId { get; } = newLeaderId; public string NewLeaderId { get; } = newLeaderId;
/// <summary>
/// Gets new leader term.
/// </summary>
public int NewTerm { get; } = newTerm; public int NewTerm { get; } = newTerm;
} }
@@ -30,13 +30,25 @@ public sealed class ConsumerManager : IDisposable
/// </summary> /// </summary>
public StreamManager? StreamManager { get; set; } 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) public ConsumerManager(JetStreamMetaGroup? metaGroup = null)
{ {
_metaGroup = metaGroup; _metaGroup = metaGroup;
} }
/// <summary>
/// Gets the number of registered consumers across all streams.
/// </summary>
public int ConsumerCount => _consumers.Count; 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) public JetStreamApiResponse CreateOrUpdate(string stream, ConsumerConfig config)
{ {
if (string.IsNullOrWhiteSpace(config.DurableName)) 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) public JetStreamApiResponse GetInfo(string stream, string durableName)
{ {
if (_consumers.TryGetValue((stream, durableName), out var handle)) 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}"); 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) public bool TryGet(string stream, string durableName, out ConsumerHandle handle)
=> _consumers.TryGetValue((stream, durableName), out 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) public bool Delete(string stream, string durableName)
{ {
CancelResumeTimer((stream, durableName)); CancelResumeTimer((stream, durableName));
return _consumers.TryRemove((stream, durableName), out _); 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) public IReadOnlyList<string> ListNames(string stream)
=> _consumers.Keys => _consumers.Keys
.Where(k => string.Equals(k.Stream, stream, StringComparison.Ordinal)) .Where(k => string.Equals(k.Stream, stream, StringComparison.Ordinal))
@@ -126,6 +158,10 @@ public sealed class ConsumerManager : IDisposable
.OrderBy(x => x, StringComparer.Ordinal) .OrderBy(x => x, StringComparer.Ordinal)
.ToArray(); .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) public IReadOnlyList<JetStreamConsumerInfo> ListConsumerInfos(string stream)
=> _consumers => _consumers
.Where(kv => string.Equals(kv.Key.Stream, stream, StringComparison.Ordinal)) .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 }) .Select(kv => new JetStreamConsumerInfo { Name = kv.Value.Config.DurableName, StreamName = stream, Config = kv.Value.Config })
.ToList(); .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) public bool Pause(string stream, string durableName, bool paused)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) 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. /// A background timer will auto-resume the consumer when the deadline passes.
/// Go reference: consumer.go (pauseConsumer). /// Go reference: consumer.go (pauseConsumer).
/// </summary> /// </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) public bool Pause(string stream, string durableName, DateTime pauseUntilUtc)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) 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. /// Explicitly resume a paused consumer, cancelling any pending auto-resume timer.
/// Go reference: consumer.go (resumeConsumer). /// Go reference: consumer.go (resumeConsumer).
/// </summary> /// </summary>
/// <param name="stream">Stream name containing the consumer.</param>
/// <param name="durableName">Consumer durable name.</param>
public bool Resume(string stream, string durableName) public bool Resume(string stream, string durableName)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) 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. /// If the deadline has passed, auto-resumes the consumer and returns false.
/// Go reference: consumer.go (isPaused). /// Go reference: consumer.go (isPaused).
/// </summary> /// </summary>
/// <param name="stream">Stream name containing the consumer.</param>
/// <param name="durableName">Consumer durable name.</param>
public bool IsPaused(string stream, string durableName) public bool IsPaused(string stream, string durableName)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) 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. /// Returns the UTC deadline until which the consumer is paused, or null.
/// Go reference: consumer.go (pauseUntil). /// Go reference: consumer.go (pauseUntil).
/// </summary> /// </summary>
/// <param name="stream">Stream name containing the consumer.</param>
/// <param name="durableName">Consumer durable name.</param>
public DateTime? GetPauseUntil(string stream, string durableName) public DateTime? GetPauseUntil(string stream, string durableName)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) if (!_consumers.TryGetValue((stream, durableName), out var handle))
@@ -246,6 +297,9 @@ public sealed class ConsumerManager : IDisposable
timer.Dispose(); timer.Dispose();
} }
/// <summary>
/// Disposes active resume timers and clears timer registry.
/// </summary>
public void Dispose() public void Dispose()
{ {
foreach (var timer in _resumeTimers.Values) foreach (var timer in _resumeTimers.Values)
@@ -253,6 +307,11 @@ public sealed class ConsumerManager : IDisposable
_resumeTimers.Clear(); _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) public bool Reset(string stream, string durableName)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) if (!_consumers.TryGetValue((stream, durableName), out var handle))
@@ -268,6 +327,9 @@ public sealed class ConsumerManager : IDisposable
/// Clears pending acks and redelivery state. /// Clears pending acks and redelivery state.
/// Go reference: consumer.go:4241 processResetReq. /// Go reference: consumer.go:4241 processResetReq.
/// </summary> /// </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) public bool ResetToSequence(string stream, string durableName, ulong sequence)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) if (!_consumers.TryGetValue((stream, durableName), out var handle))
@@ -285,14 +347,35 @@ public sealed class ConsumerManager : IDisposable
return true; 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) public bool Unpin(string stream, string durableName)
{ {
return _consumers.ContainsKey((stream, 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) 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); => 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) public async ValueTask<PullFetchBatch> FetchAsync(string stream, string durableName, PullFetchRequest request, StreamManager streamManager, CancellationToken ct)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var consumer)) 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); 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) public bool AckAll(string stream, string durableName, ulong sequence)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) if (!_consumers.TryGetValue((stream, durableName), out var handle))
@@ -314,6 +403,11 @@ public sealed class ConsumerManager : IDisposable
return true; 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) public int GetPendingCount(string stream, string durableName)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var handle)) 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. /// Returns true if there are any consumers registered for the given stream.
/// Used to short-circuit the LoadAsync call on the publish hot path. /// Used to short-circuit the LoadAsync call on the publish hot path.
/// </summary> /// </summary>
/// <param name="stream">Stream name to check.</param>
public bool HasConsumersForStream(string stream) public bool HasConsumersForStream(string stream)
=> _consumers.Keys.Any(k => string.Equals(k.Stream, stream, StringComparison.Ordinal)); => _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) public void OnPublished(string stream, StoredMessage message)
{ {
foreach (var handle in _consumers.Values.Where(c => c.Stream == stream && c.Config.Push)) 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) public PushFrame? ReadPushFrame(string stream, string durableName)
{ {
if (!_consumers.TryGetValue((stream, durableName), out var consumer)) if (!_consumers.TryGetValue((stream, durableName), out var consumer))
@@ -369,6 +474,10 @@ public sealed class ConsumerManager : IDisposable
return true; 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) internal ulong GetAckFloor(string stream)
=> _ackFloors.TryGetValue(stream, out var ackFloor) ? ackFloor : 0; => _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 Consumers.CompiledFilter? _compiledFilter;
private string? _compiledFilterSubject; private string? _compiledFilterSubject;
private int _compiledFilterSubjectsCount; private int _compiledFilterSubjectsCount;
/// <summary>
/// Gets cached compiled subject filter for this consumer configuration.
/// </summary>
public Consumers.CompiledFilter CompiledFilter public Consumers.CompiledFilter CompiledFilter
{ {
get 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; public ulong NextSequence { get; set; } = 1;
/// <summary>
/// Gets or sets whether delivery is currently paused.
/// </summary>
public bool Paused { get; set; } public bool Paused { get; set; }
/// <summary> /// <summary>
@@ -409,9 +528,24 @@ public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
/// (until explicitly resumed). Go reference: consumer.go pauseUntil field. /// (until explicitly resumed). Go reference: consumer.go pauseUntil field.
/// </summary> /// </summary>
public DateTime? PauseUntilUtc { get; set; } public DateTime? PauseUntilUtc { get; set; }
/// <summary>
/// Gets pending stored messages queued for this consumer.
/// </summary>
public Queue<StoredMessage> Pending { get; } = new(); public Queue<StoredMessage> Pending { get; } = new();
/// <summary>
/// Gets queued push frames waiting for delivery window release.
/// </summary>
public Queue<PushFrame> PushFrames { get; } = new(); public Queue<PushFrame> PushFrames { get; } = new();
/// <summary>
/// Gets ack processor state for pending and ack-floor tracking.
/// </summary>
public AckProcessor AckProcessor { get; } = new(); public AckProcessor AckProcessor { get; } = new();
/// <summary>
/// Gets or sets next UTC time when push data can be delivered.
/// </summary>
public DateTime NextPushDataAvailableAtUtc { get; set; } public DateTime NextPushDataAvailableAtUtc { get; set; }
/// <summary> /// <summary>
@@ -19,6 +19,10 @@ public sealed class CompiledFilter
private readonly string? _singleFilter; private readonly string? _singleFilter;
private readonly bool _matchAll; 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) public CompiledFilter(IReadOnlyList<string> filterSubjects)
{ {
if (filterSubjects.Count == 0) if (filterSubjects.Count == 0)
@@ -52,6 +56,7 @@ public sealed class CompiledFilter
/// <summary> /// <summary>
/// Returns <c>true</c> if the given subject matches any of the compiled filter patterns. /// Returns <c>true</c> if the given subject matches any of the compiled filter patterns.
/// </summary> /// </summary>
/// <param name="subject">Publish subject to evaluate.</param>
public bool Matches(string subject) public bool Matches(string subject)
{ {
if (_matchAll) if (_matchAll)
@@ -81,6 +86,7 @@ public sealed class CompiledFilter
/// Uses <see cref="ConsumerConfig.FilterSubjects"/> first, falling back to /// Uses <see cref="ConsumerConfig.FilterSubjects"/> first, falling back to
/// <see cref="ConsumerConfig.FilterSubject"/> if the list is empty. /// <see cref="ConsumerConfig.FilterSubject"/> if the list is empty.
/// </summary> /// </summary>
/// <param name="config">Consumer configuration source.</param>
public static CompiledFilter FromConfig(ConsumerConfig config) public static CompiledFilter FromConfig(ConsumerConfig config)
{ {
if (config.FilterSubjects.Count > 0) 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. /// Returns true if quorum is available and the request was registered; false otherwise.
/// Go reference: consumer.go proposeWaitingRequest — propose via consumer RAFT group. /// Go reference: consumer.go proposeWaitingRequest — propose via consumer RAFT group.
/// </summary> /// </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) public bool ProposeWaitingRequest(PullWaitingRequest request, RaftGroup group)
{ {
if (!group.HasQuorum(group.Peers.Count)) 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. /// Registers a pull request in the cluster pending tracker, keyed by reply subject.
/// Go reference: consumer.go — cluster pending registration on proposal acceptance. /// Go reference: consumer.go — cluster pending registration on proposal acceptance.
/// </summary> /// </summary>
/// <param name="request">Pull request to register.</param>
public void RegisterClusterPending(PullWaitingRequest request) public void RegisterClusterPending(PullWaitingRequest request)
{ {
var replyKey = request.Reply ?? string.Empty; 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. /// Returns null if no request is registered for that reply subject.
/// Go reference: consumer.go — cluster pending removal on fulfillment or expiry. /// Go reference: consumer.go — cluster pending removal on fulfillment or expiry.
/// </summary> /// </summary>
/// <param name="replySubject">Reply subject key for the pending request.</param>
public PullWaitingRequest? RemoveClusterPending(string replySubject) public PullWaitingRequest? RemoveClusterPending(string replySubject)
{ {
_clusterPending.TryRemove(replySubject, out var request); _clusterPending.TryRemove(replySubject, out var request);
@@ -149,9 +159,23 @@ public sealed class PullConsumerEngine
public IReadOnlyCollection<PullWaitingRequest> GetClusterPendingRequests() public IReadOnlyCollection<PullWaitingRequest> GetClusterPendingRequests()
=> _clusterPending.Values.ToArray(); => _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) public async ValueTask<PullFetchBatch> FetchAsync(StreamHandle stream, ConsumerHandle consumer, int batch, CancellationToken ct)
=> await FetchAsync(stream, consumer, new PullFetchRequest { Batch = batch }, 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) public async ValueTask<PullFetchBatch> FetchAsync(StreamHandle stream, ConsumerHandle consumer, PullFetchRequest request, CancellationToken ct)
{ {
var batch = Math.Max(request.Batch, 1); var batch = Math.Max(request.Batch, 1);
@@ -356,9 +380,21 @@ public sealed class PullConsumerEngine
public sealed class PullFetchBatch public sealed class PullFetchBatch
{ {
/// <summary>
/// Messages returned by the fetch operation.
/// </summary>
public IReadOnlyList<StoredMessage> Messages { get; } public IReadOnlyList<StoredMessage> Messages { get; }
/// <summary>
/// Indicates whether fetch ended due to Expires timeout.
/// </summary>
public bool TimedOut { get; } 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) public PullFetchBatch(IReadOnlyList<StoredMessage> messages, bool timedOut = false)
{ {
// Snapshot: caller may reuse the list (ThreadStatic pooling), so take a copy. // Snapshot: caller may reuse the list (ThreadStatic pooling), so take a copy.
@@ -369,11 +405,25 @@ public sealed class PullFetchBatch
public sealed class PullFetchRequest public sealed class PullFetchRequest
{ {
/// <summary>
/// Maximum number of messages to return.
/// </summary>
public int Batch { get; init; } = 1; public int Batch { get; init; } = 1;
/// <summary>
/// When true, returns immediately if no message is available.
/// </summary>
public bool NoWait { get; init; } public bool NoWait { get; init; }
/// <summary>
/// Maximum wait time in milliseconds before timing out.
/// </summary>
public int ExpiresMs { get; init; } public int ExpiresMs { get; init; }
// Go: consumer.go — max_bytes limits total bytes per fetch request // Go: consumer.go — max_bytes limits total bytes per fetch request
// Reference: golang/nats-server/server/consumer.go — maxRequestBytes // 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; } public long MaxBytes { get; init; }
} }
@@ -384,8 +434,15 @@ public sealed class PullRequestWaitQueue
private readonly int _maxSize; private readonly int _maxSize;
private readonly List<PullWaitingRequest> _items = new(); 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; public PullRequestWaitQueue(int maxSize = int.MaxValue) => _maxSize = maxSize;
/// <summary>
/// Number of pending requests in the queue.
/// </summary>
public int Count => _items.Count; public int Count => _items.Count;
/// <summary> /// <summary>
@@ -393,6 +450,7 @@ public sealed class PullRequestWaitQueue
/// Returns false if the queue is at capacity. /// Returns false if the queue is at capacity.
/// Go: consumer.go — waitQueue.addPrioritized with sort.SliceStable semantics. /// Go: consumer.go — waitQueue.addPrioritized with sort.SliceStable semantics.
/// </summary> /// </summary>
/// <param name="request">Pull request to enqueue.</param>
public bool Enqueue(PullWaitingRequest request) public bool Enqueue(PullWaitingRequest request)
{ {
if (_maxSize > 0 && _items.Count >= _maxSize) if (_maxSize > 0 && _items.Count >= _maxSize)
@@ -412,9 +470,15 @@ public sealed class PullRequestWaitQueue
return true; return true;
} }
/// <summary>
/// Returns the next queued request without removing it.
/// </summary>
public PullWaitingRequest? Peek() public PullWaitingRequest? Peek()
=> _items.Count > 0 ? _items[0] : null; => _items.Count > 0 ? _items[0] : null;
/// <summary>
/// Removes and returns the next queued request.
/// </summary>
public PullWaitingRequest? Dequeue() public PullWaitingRequest? Dequeue()
{ {
if (_items.Count == 0) return null; if (_items.Count == 0) return null;
@@ -454,6 +518,10 @@ public sealed class PullRequestWaitQueue
return decremented; return decremented;
} }
/// <summary>
/// Attempts to dequeue one request.
/// </summary>
/// <param name="request">Dequeued request when available.</param>
public bool TryDequeue(out PullWaitingRequest? request) public bool TryDequeue(out PullWaitingRequest? request)
{ {
request = Dequeue(); request = Dequeue();
@@ -465,10 +533,33 @@ public sealed class PullRequestWaitQueue
// Reference: golang/nats-server/server/consumer.go waitingRequest // Reference: golang/nats-server/server/consumer.go waitingRequest
public sealed record PullWaitingRequest public sealed record PullWaitingRequest
{ {
/// <summary>
/// Priority where lower values are served first.
/// </summary>
public int Priority { get; init; } public int Priority { get; init; }
/// <summary>
/// Requested batch size.
/// </summary>
public int Batch { get; init; } = 1; public int Batch { get; init; } = 1;
/// <summary>
/// Remaining messages to deliver for this queued request.
/// </summary>
public int RemainingBatch { get; init; } = 1; public int RemainingBatch { get; init; } = 1;
/// <summary>
/// Optional per-request max bytes budget.
/// </summary>
public long MaxBytes { get; init; } public long MaxBytes { get; init; }
/// <summary>
/// Optional expiration in milliseconds.
/// </summary>
public int ExpiresMs { get; init; } public int ExpiresMs { get; init; }
/// <summary>
/// Reply subject used to track and fulfill the request.
/// </summary>
public string? Reply { get; init; } public string? Reply { get; init; }
} }
@@ -1,62 +1,165 @@
namespace NATS.Server.JetStream.Models; namespace NATS.Server.JetStream.Models;
/// <summary>
/// JetStream consumer configuration that controls delivery, acknowledgement, and flow behavior.
/// </summary>
public sealed class ConsumerConfig public sealed class ConsumerConfig
{ {
/// <summary>
/// Durable consumer name. Required for durable consumers; generated for ephemerals.
/// </summary>
public string DurableName { get; set; } = string.Empty; 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; } public bool Ephemeral { get; set; }
/// <summary>
/// Legacy single filter subject used for subject-scoped delivery.
/// </summary>
public string? FilterSubject { get; set; } public string? FilterSubject { get; set; }
/// <summary>
/// Multi-filter subject list used for subject-scoped delivery.
/// </summary>
public List<string> FilterSubjects { get; set; } = []; public List<string> FilterSubjects { get; set; } = [];
/// <summary>
/// Acknowledgement policy for delivered messages.
/// </summary>
public AckPolicy AckPolicy { get; set; } = AckPolicy.None; public AckPolicy AckPolicy { get; set; } = AckPolicy.None;
/// <summary>
/// Start-position policy used when initializing delivery.
/// </summary>
public DeliverPolicy DeliverPolicy { get; set; } = DeliverPolicy.All; public DeliverPolicy DeliverPolicy { get; set; } = DeliverPolicy.All;
/// <summary>
/// Explicit starting sequence used by sequence-based deliver policies.
/// </summary>
public ulong OptStartSeq { get; set; } public ulong OptStartSeq { get; set; }
/// <summary>
/// Explicit UTC start time used by time-based deliver policies.
/// </summary>
public DateTime? OptStartTimeUtc { get; set; } public DateTime? OptStartTimeUtc { get; set; }
/// <summary>
/// Replay speed policy for historical messages.
/// </summary>
public ReplayPolicy ReplayPolicy { get; set; } = ReplayPolicy.Instant; public ReplayPolicy ReplayPolicy { get; set; } = ReplayPolicy.Instant;
/// <summary>
/// Acknowledgement wait timeout in milliseconds.
/// </summary>
public int AckWaitMs { get; set; } = 30_000; 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; public int MaxDeliver { get; set; } = 1;
/// <summary>
/// Maximum number of unacknowledged messages allowed for this consumer.
/// </summary>
public int MaxAckPending { get; set; } public int MaxAckPending { get; set; }
/// <summary>
/// Enables push delivery mode. When false, the consumer is pull-based.
/// </summary>
public bool Push { get; set; } public bool Push { get; set; }
// Go: consumer.go:115 — deliver_subject routes push messages to a NATS subject // 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; public string DeliverSubject { get; set; } = string.Empty;
/// <summary>
/// Idle heartbeat interval in milliseconds for push consumers.
/// </summary>
public int HeartbeatMs { get; set; } public int HeartbeatMs { get; set; }
/// <summary>
/// Redelivery backoff schedule in milliseconds.
/// </summary>
public List<int> BackOffMs { get; set; } = []; public List<int> BackOffMs { get; set; } = [];
/// <summary>
/// Enables flow control for push delivery.
/// </summary>
public bool FlowControl { get; set; } public bool FlowControl { get; set; }
/// <summary>
/// Optional egress rate limit for delivery, in bits per second.
/// </summary>
public long RateLimitBps { get; set; } public long RateLimitBps { get; set; }
// Go: consumer.go — max_waiting limits the number of queued pull requests // 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; } public int MaxWaiting { get; set; }
// Go: consumer.go — max_request_batch limits batch size per pull request // 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; } public int MaxRequestBatch { get; set; }
// Go: consumer.go — max_request_max_bytes limits bytes per pull request // 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; } public int MaxRequestMaxBytes { get; set; }
// Go: consumer.go — max_request_expires limits expires duration per pull request (ms) // 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; } public int MaxRequestExpiresMs { get; set; }
// Go: ConsumerConfig.PauseUntil — pauses consumer delivery until this UTC time. // Go: ConsumerConfig.PauseUntil — pauses consumer delivery until this UTC time.
// Null or zero time means not paused. // Null or zero time means not paused.
// Added in v2.11, requires API level 1. // Added in v2.11, requires API level 1.
// Go reference: server/consumer.go (PauseUntil field) // Go reference: server/consumer.go (PauseUntil field)
/// <summary>
/// UTC time until which consumer delivery is paused.
/// </summary>
public DateTime? PauseUntil { get; set; } public DateTime? PauseUntil { get; set; }
// Go: ConsumerConfig.PriorityPolicy — consumer priority routing policy. // Go: ConsumerConfig.PriorityPolicy — consumer priority routing policy.
// PriorityPinnedClient requires API level 1. // PriorityPinnedClient requires API level 1.
// Go reference: server/consumer.go (PriorityPolicy field) // 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; public PriorityPolicy PriorityPolicy { get; set; } = PriorityPolicy.None;
// Go: ConsumerConfig.PriorityGroups — list of priority group names. // Go: ConsumerConfig.PriorityGroups — list of priority group names.
// Go reference: server/consumer.go (PriorityGroups field) // Go reference: server/consumer.go (PriorityGroups field)
/// <summary>
/// Priority group names used by priority-based routing.
/// </summary>
public List<string> PriorityGroups { get; set; } = []; public List<string> PriorityGroups { get; set; } = [];
// Go: ConsumerConfig.PinnedTTL — TTL for pinned client assignment. // Go: ConsumerConfig.PinnedTTL — TTL for pinned client assignment.
// Go reference: server/consumer.go (PinnedTTL field) // Go reference: server/consumer.go (PinnedTTL field)
/// <summary>
/// Pinning TTL in milliseconds for pinned-client priority assignments.
/// </summary>
public long PinnedTtlMs { get; set; } public long PinnedTtlMs { get; set; }
// Go: ConsumerConfig.Metadata — user-supplied and server-managed key/value metadata. // Go: ConsumerConfig.Metadata — user-supplied and server-managed key/value metadata.
// Go reference: server/consumer.go (Metadata field) // Go reference: server/consumer.go (Metadata field)
/// <summary>
/// Arbitrary metadata associated with the consumer.
/// </summary>
public Dictionary<string, string>? Metadata { get; set; } 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() public string? ResolvePrimaryFilterSubject()
{ {
if (FilterSubjects.Count > 0) if (FilterSubjects.Count > 0)
@@ -46,11 +46,34 @@ public static class AtomicBatchPublishErrorCodes
/// </summary> /// </summary>
public sealed class StagedBatchMessage public sealed class StagedBatchMessage
{ {
/// <summary>
/// Target subject for the staged publish.
/// </summary>
public required string Subject { get; init; } public required string Subject { get; init; }
/// <summary>
/// Payload bytes for the staged publish.
/// </summary>
public required ReadOnlyMemory<byte> Payload { get; init; } public required ReadOnlyMemory<byte> Payload { get; init; }
/// <summary>
/// Optional Nats-Msg-Id value used for duplicate detection.
/// </summary>
public string? MsgId { get; init; } public string? MsgId { get; init; }
/// <summary>
/// Optional expected last stream sequence precondition.
/// </summary>
public ulong ExpectedLastSeq { get; init; } public ulong ExpectedLastSeq { get; init; }
/// <summary>
/// Optional expected last sequence precondition for a specific subject.
/// </summary>
public ulong ExpectedLastSubjectSeq { get; init; } public ulong ExpectedLastSubjectSeq { get; init; }
/// <summary>
/// Subject used with <see cref="ExpectedLastSubjectSeq"/> precondition.
/// </summary>
public string? ExpectedLastSubjectSeqSubject { get; init; } public string? ExpectedLastSubjectSeqSubject { get; init; }
} }
@@ -63,10 +86,25 @@ internal sealed class InFlightBatch
private readonly List<StagedBatchMessage> _messages = []; private readonly List<StagedBatchMessage> _messages = [];
private readonly HashSet<string> _stagedMsgIds = new(StringComparer.Ordinal); private readonly HashSet<string> _stagedMsgIds = new(StringComparer.Ordinal);
/// <summary>
/// UTC creation timestamp used for batch-timeout eviction.
/// </summary>
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
/// <summary>
/// Number of staged messages currently held in the batch.
/// </summary>
public int Count => _messages.Count; public int Count => _messages.Count;
/// <summary>
/// Staged messages in receive order.
/// </summary>
public IReadOnlyList<StagedBatchMessage> Messages => _messages; 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) public void Add(StagedBatchMessage msg)
{ {
_messages.Add(msg); _messages.Add(msg);
@@ -74,6 +112,10 @@ internal sealed class InFlightBatch
_stagedMsgIds.Add(msg.MsgId); _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); public bool ContainsMsgId(string msgId) => _stagedMsgIds.Contains(msgId);
} }
@@ -104,6 +146,12 @@ public sealed class AtomicBatchPublishEngine
private readonly int _maxBatchSize; private readonly int _maxBatchSize;
private readonly TimeSpan _batchTimeout; 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( public AtomicBatchPublishEngine(
int maxInflightPerStream = DefaultMaxInflightPerStream, int maxInflightPerStream = DefaultMaxInflightPerStream,
int maxBatchSize = DefaultMaxBatchSize, int maxBatchSize = DefaultMaxBatchSize,
@@ -123,6 +171,10 @@ public sealed class AtomicBatchPublishEngine
/// Validates and stages/commits a batch message. /// Validates and stages/commits a batch message.
/// Returns a result indicating: stage (empty ack), commit (full ack), or error. /// Returns a result indicating: stage (empty ack), commit (full ack), or error.
/// </summary> /// </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( public AtomicBatchResult Process(
BatchPublishRequest req, BatchPublishRequest req,
PublishPreconditions preconditions, PublishPreconditions preconditions,
@@ -312,6 +364,7 @@ public sealed class AtomicBatchPublishEngine
/// <summary> /// <summary>
/// Returns whether a batch with the given ID is currently in-flight. /// Returns whether a batch with the given ID is currently in-flight.
/// </summary> /// </summary>
/// <param name="batchId">Batch identifier from Nats-Batch-Id header.</param>
public bool HasBatch(string batchId) => _batches.ContainsKey(batchId); public bool HasBatch(string batchId) => _batches.ContainsKey(batchId);
private void EvictExpiredBatches() private void EvictExpiredBatches()
@@ -330,9 +383,24 @@ public sealed class AtomicBatchPublishEngine
/// </summary> /// </summary>
public sealed class BatchPublishRequest public sealed class BatchPublishRequest
{ {
/// <summary>
/// Batch identifier from Nats-Batch-Id.
/// </summary>
public required string BatchId { get; init; } public required string BatchId { get; init; }
/// <summary>
/// Sequence position inside the batch from Nats-Batch-Sequence.
/// </summary>
public required ulong BatchSeq { get; init; } public required ulong BatchSeq { get; init; }
/// <summary>
/// Target subject for this batch entry.
/// </summary>
public required string Subject { get; init; } public required string Subject { get; init; }
/// <summary>
/// Payload bytes for this batch entry.
/// </summary>
public required ReadOnlyMemory<byte> Payload { get; init; } public required ReadOnlyMemory<byte> Payload { get; init; }
/// <summary> /// <summary>
@@ -346,9 +414,24 @@ public sealed class BatchPublishRequest
/// </summary> /// </summary>
public string? CommitValue { get; init; } public string? CommitValue { get; init; }
/// <summary>
/// Optional Nats-Msg-Id used for duplicate detection.
/// </summary>
public string? MsgId { get; init; } public string? MsgId { get; init; }
/// <summary>
/// Expected last stream sequence precondition.
/// </summary>
public ulong ExpectedLastSeq { get; init; } public ulong ExpectedLastSeq { get; init; }
/// <summary>
/// Expected last subject sequence precondition.
/// </summary>
public ulong ExpectedLastSubjectSeq { get; init; } public ulong ExpectedLastSubjectSeq { get; init; }
/// <summary>
/// Subject used with <see cref="ExpectedLastSubjectSeq"/> precondition.
/// </summary>
public string? ExpectedLastSubjectSeqSubject { get; init; } public string? ExpectedLastSubjectSeqSubject { get; init; }
} }
@@ -359,16 +442,43 @@ public sealed class AtomicBatchResult
{ {
public enum ResultKind { Staged, Committed, Error } public enum ResultKind { Staged, Committed, Error }
/// <summary>
/// Outcome kind for processing: staged, committed, or error.
/// </summary>
public ResultKind Kind { get; private init; } public ResultKind Kind { get; private init; }
/// <summary>
/// Commit acknowledgement when <see cref="Kind"/> is committed.
/// </summary>
public PubAck? CommitAck { get; private init; } public PubAck? CommitAck { get; private init; }
/// <summary>
/// JetStream error code when <see cref="Kind"/> is error.
/// </summary>
public int ErrorCode { get; private init; } 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; 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 }; 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) => public static AtomicBatchResult Committed(PubAck ack) =>
new() { Kind = ResultKind.Committed, CommitAck = 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) => public static AtomicBatchResult Error(int code, string description) =>
new() { Kind = ResultKind.Error, ErrorCode = code, ErrorDescription = description }; new() { Kind = ResultKind.Error, ErrorCode = code, ErrorDescription = description };
} }
+60 -24
View File
@@ -56,6 +56,12 @@ public sealed class MsgBlock : IDisposable
private int _pendingBufUsed; private int _pendingBufUsed;
private long _pendingBufDiskOffset; // Disk offset corresponding to _pendingBuf[0] 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). // Go: msgBlock.lchk — last written record checksum (XxHash64, 8 bytes).
// Tracked so callers can chain checksum verification across blocks. // Tracked so callers can chain checksum verification across blocks.
// Reference: golang/nats-server/server/filestore.go:2204 (lchk field) // Reference: golang/nats-server/server/filestore.go:2204 (lchk field)
@@ -358,16 +364,22 @@ public sealed class MsgBlock : IDisposable
/// <summary> /// <summary>
/// Flushes the contiguous pending buffer to disk in a single write. /// 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> /// </summary>
private void FlushPendingBufToDisk() private void FlushPendingBufToDisk()
{ {
if (_pendingBufUsed == 0) // Wait for any in-flight double-buffer FlushPending to finish writing,
return; // 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); RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset);
_pendingBufDiskOffset += _pendingBufUsed; _pendingBufDiskOffset += _pendingBufUsed;
_pendingBufUsed = 0; _pendingBufUsed = 0;
}
} }
/// <summary> /// <summary>
@@ -571,6 +583,8 @@ public sealed class MsgBlock : IDisposable
/// <summary> /// <summary>
/// Flushes all buffered (pending) writes to disk in a single batch. /// 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 /// Called by the background flush loop in FileStore, or synchronously on
/// block seal / dispose to ensure all data reaches disk. /// block seal / dispose to ensure all data reaches disk.
/// Reference: golang/nats-server/server/filestore.go:7592 (flushPendingMsgsLocked). /// Reference: golang/nats-server/server/filestore.go:7592 (flushPendingMsgsLocked).
@@ -581,30 +595,52 @@ public sealed class MsgBlock : IDisposable
if (_disposed) if (_disposed)
return 0; return 0;
try int bytesToFlush;
{ byte[] bufToFlush;
_lock.EnterWriteLock(); long diskOffset;
}
catch (ObjectDisposedException)
{
// Block was disposed concurrently (e.g. during PurgeAsync).
return 0;
}
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; return 0;
}
// Single contiguous write — Go: flushPendingMsgsLocked writes cache.buf[wp:] to disk. try
RandomAccess.Write(_handle, _pendingBuf.AsSpan(0, _pendingBufUsed), _pendingBufDiskOffset); {
if (_pendingBufUsed == 0)
return 0;
var flushed = _pendingBufUsed; bytesToFlush = _pendingBufUsed;
_pendingBufDiskOffset += _pendingBufUsed; diskOffset = _pendingBufDiskOffset;
_pendingBufUsed = 0;
return flushed; // 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> /// <summary>
+108
View File
@@ -31,6 +31,13 @@ public sealed class StreamManager : IDisposable
private readonly string? _storeDir; private readonly string? _storeDir;
private Task? _expiryTimerTask; 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) public StreamManager(JetStreamMetaGroup? metaGroup = null, Account? account = null, ConsumerManager? consumerManager = null, string? storeDir = null)
{ {
_metaGroup = metaGroup; _metaGroup = metaGroup;
@@ -40,6 +47,9 @@ public sealed class StreamManager : IDisposable
_expiryTimerTask = RunExpiryTimerAsync(_expiryTimerCts.Token); _expiryTimerTask = RunExpiryTimerAsync(_expiryTimerCts.Token);
} }
/// <summary>
/// Stops background expiry processing and releases manager resources.
/// </summary>
public void Dispose() public void Dispose()
{ {
_expiryTimerCts.Cancel(); _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(); public IReadOnlyCollection<string> StreamNames => _streams.Keys.ToArray();
/// <summary>
/// Gets current JetStream meta-group state when clustering is enabled.
/// </summary>
public MetaGroupState? GetMetaState() => _metaGroup?.GetState(); public MetaGroupState? GetMetaState() => _metaGroup?.GetState();
/// <summary>
/// Lists stream names sorted in ordinal order for deterministic API responses.
/// </summary>
public IReadOnlyList<string> ListNames() public IReadOnlyList<string> ListNames()
=> [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)]; => [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)];
/// <summary>
/// Lists stream info payloads including current storage state for each stream.
/// </summary>
public IReadOnlyList<JetStreamStreamInfo> ListStreamInfos() public IReadOnlyList<JetStreamStreamInfo> ListStreamInfos()
{ {
return _streams.OrderBy(kv => kv.Key, StringComparer.Ordinal) return _streams.OrderBy(kv => kv.Key, StringComparer.Ordinal)
@@ -98,6 +121,10 @@ public sealed class StreamManager : IDisposable
.ToList(); .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) public JetStreamApiResponse CreateOrUpdate(StreamConfig config)
{ {
if (!JetStreamConfigValidator.IsValidName(config.Name)) if (!JetStreamConfigValidator.IsValidName(config.Name))
@@ -207,6 +234,10 @@ public sealed class StreamManager : IDisposable
return BuildStreamInfoResponse(handle); 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) public JetStreamApiResponse GetInfo(string name)
{ {
if (_streams.TryGetValue(name, out var stream)) if (_streams.TryGetValue(name, out var stream))
@@ -215,10 +246,23 @@ public sealed class StreamManager : IDisposable
return JetStreamApiResponse.NotFound($"$JS.API.STREAM.INFO.{name}"); 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!); 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); 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) public bool Delete(string name)
{ {
if (!_streams.TryRemove(name, out _)) if (!_streams.TryRemove(name, out _))
@@ -235,6 +279,10 @@ public sealed class StreamManager : IDisposable
return true; 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) public bool Purge(string name)
{ {
if (!_streams.TryGetValue(name, out var stream)) 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. /// 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. /// Go reference: jetstream_api.go:1200-1350 — purge options: filter, seq, keep.
/// </summary> /// </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) public long PurgeEx(string name, string? filter, ulong? seq, ulong? keep)
{ {
if (!_streams.TryGetValue(name, out var stream)) if (!_streams.TryGetValue(name, out var stream))
@@ -337,6 +389,11 @@ public sealed class StreamManager : IDisposable
return purged; 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) public StoredMessage? GetMessage(string name, ulong sequence)
{ {
if (!_streams.TryGetValue(name, out var stream)) if (!_streams.TryGetValue(name, out var stream))
@@ -345,6 +402,11 @@ public sealed class StreamManager : IDisposable
return stream.Store.LoadAsync(sequence, default).GetAwaiter().GetResult(); 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) public bool DeleteMessage(string name, ulong sequence)
{ {
if (!_streams.TryGetValue(name, out var stream)) if (!_streams.TryGetValue(name, out var stream))
@@ -355,6 +417,10 @@ public sealed class StreamManager : IDisposable
return stream.Store.RemoveAsync(sequence, default).GetAwaiter().GetResult(); 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) public byte[]? CreateSnapshot(string name)
{ {
if (!_streams.TryGetValue(name, out var stream)) if (!_streams.TryGetValue(name, out var stream))
@@ -363,6 +429,11 @@ public sealed class StreamManager : IDisposable
return _snapshotService.SnapshotAsync(stream, default).GetAwaiter().GetResult(); 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) public bool RestoreSnapshot(string name, ReadOnlyMemory<byte> snapshot)
{ {
if (!_streams.TryGetValue(name, out var stream)) if (!_streams.TryGetValue(name, out var stream))
@@ -372,6 +443,11 @@ public sealed class StreamManager : IDisposable
return true; 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) public ValueTask<Models.ApiStreamState> GetStateAsync(string name, CancellationToken ct)
{ {
if (_streams.TryGetValue(name, out var stream)) if (_streams.TryGetValue(name, out var stream))
@@ -380,6 +456,10 @@ public sealed class StreamManager : IDisposable
return ValueTask.FromResult(new Models.ApiStreamState()); 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) public StreamHandle? FindBySubject(string subject)
{ {
foreach (var stream in _streams.Values) foreach (var stream in _streams.Values)
@@ -391,6 +471,11 @@ public sealed class StreamManager : IDisposable
return null; 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) public PubAck? Capture(string subject, ReadOnlyMemory<byte> payload)
{ {
var stream = FindBySubject(subject); var stream = FindBySubject(subject);
@@ -400,6 +485,12 @@ public sealed class StreamManager : IDisposable
return Capture(stream, subject, payload); 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) public PubAck? Capture(StreamHandle stream, string subject, ReadOnlyMemory<byte> payload)
{ {
// Go: sealed stream rejects all publishes. // 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, /// The server loads the last stored value for the subject, adds the increment,
/// and stores the new total as a JSON payload. /// and stores the new total as a JSON payload.
/// </summary> /// </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) public PubAck? CaptureCounter(string subject, long increment)
{ {
var stream = FindBySubject(subject); 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) public Task StepDownStreamLeaderAsync(string stream, CancellationToken ct)
{ {
if (_replicaGroups.TryGetValue(stream, out var replicaGroup)) 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. /// 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). /// Go reference: server/stream.go:1500-1600 (stream.update immutable-field checks).
/// </summary> /// </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( public static IReadOnlyList<string> ValidateConfigUpdate(
StreamConfig existing, StreamConfig existing,
StreamConfig proposed, 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) public string GetStoreBackendType(string streamName)
{ {
if (!_streams.TryGetValue(streamName, out var stream)) if (!_streams.TryGetValue(streamName, out var stream))
@@ -920,6 +1025,7 @@ public sealed class StreamManager : IDisposable
/// or is not configured as a mirror. /// or is not configured as a mirror.
/// Go reference: server/stream.go:2739-2743 (mirrorInfo) /// Go reference: server/stream.go:2739-2743 (mirrorInfo)
/// </summary> /// </summary>
/// <param name="streamName">Mirror stream name.</param>
public MirrorInfoResponse? GetMirrorInfo(string streamName) public MirrorInfoResponse? GetMirrorInfo(string streamName)
{ {
if (!_streams.TryGetValue(streamName, out var stream)) 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. /// Returns an empty array when the stream does not exist or has no sources.
/// Go reference: server/stream.go:2687-2695 (sourcesInfo) /// Go reference: server/stream.go:2687-2695 (sourcesInfo)
/// </summary> /// </summary>
/// <param name="streamName">Stream name.</param>
public SourceInfoResponse[] GetSourceInfos(string streamName) public SourceInfoResponse[] GetSourceInfos(string streamName)
{ {
if (!_streams.TryGetValue(streamName, out _)) if (!_streams.TryGetValue(streamName, out _))
@@ -993,6 +1100,7 @@ public sealed record StreamHandle(StreamConfig Config, IStreamStore Store)
/// <summary> /// <summary>
/// Waits until a new message is published to this stream. /// Waits until a new message is published to this stream.
/// </summary> /// </summary>
/// <param name="ct">Cancellation token used to stop waiting.</param>
public Task WaitForPublishAsync(CancellationToken ct) public Task WaitForPublishAsync(CancellationToken ct)
=> _publishSignal.Task.WaitAsync(ct); => _publishSignal.Task.WaitAsync(ct);
} }
@@ -54,6 +54,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// </summary> /// </summary>
internal static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(60); 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}"; public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
/// <summary> /// <summary>
@@ -93,6 +96,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// disabled. /// disabled.
/// Go reference: leafnode.go isLeafConnectDisabled. /// Go reference: leafnode.go isLeafConnectDisabled.
/// </summary> /// </summary>
/// <param name="remoteUrl">Remote leaf URL to evaluate.</param>
public bool IsLeafConnectDisabled(string remoteUrl) public bool IsLeafConnectDisabled(string remoteUrl)
=> IsGloballyDisabled || _disabledRemotes.ContainsKey(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. /// Returns true when the remote URL is still configured and not disabled.
/// Go reference: leafnode.go remoteLeafNodeStillValid. /// Go reference: leafnode.go remoteLeafNodeStillValid.
/// </summary> /// </summary>
/// <param name="remoteUrl">Remote leaf URL to validate.</param>
internal bool RemoteLeafNodeStillValid(string remoteUrl) internal bool RemoteLeafNodeStillValid(string remoteUrl)
{ {
if (IsLeafConnectDisabled(remoteUrl)) if (IsLeafConnectDisabled(remoteUrl))
@@ -122,6 +127,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// Has no effect if the remote is already disabled. /// Has no effect if the remote is already disabled.
/// Go reference: leafnode.go isLeafConnectDisabled — per-remote disable tracking. /// Go reference: leafnode.go isLeafConnectDisabled — per-remote disable tracking.
/// </summary> /// </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) public void DisableLeafConnect(string remoteUrl, string? reason = null)
{ {
_disabledRemotes.TryAdd(remoteUrl, true); _disabledRemotes.TryAdd(remoteUrl, true);
@@ -134,6 +141,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// Re-enables outbound leaf connections to the specified remote URL. /// Re-enables outbound leaf connections to the specified remote URL.
/// Has no effect if the remote was not disabled. /// Has no effect if the remote was not disabled.
/// </summary> /// </summary>
/// <param name="remoteUrl">Remote leaf URL to re-enable.</param>
public void EnableLeafConnect(string remoteUrl) public void EnableLeafConnect(string remoteUrl)
{ {
_disabledRemotes.TryRemove(remoteUrl, out _); _disabledRemotes.TryRemove(remoteUrl, out _);
@@ -145,6 +153,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// Per-remote disable state is preserved. /// Per-remote disable state is preserved.
/// Go reference: leafnode.go isLeafConnectDisabled — global flag. /// Go reference: leafnode.go isLeafConnectDisabled — global flag.
/// </summary> /// </summary>
/// <param name="reason">Optional operator reason for diagnostics.</param>
public void DisableAllLeafConnections(string? reason = null) public void DisableAllLeafConnections(string? reason = null)
{ {
IsGloballyDisabled = true; IsGloballyDisabled = true;
@@ -181,6 +190,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// no state is mutated. /// no state is mutated.
/// Go reference: leafnode.go — reloadTLSConfig hot-reload path. /// Go reference: leafnode.go — reloadTLSConfig hot-reload path.
/// </summary> /// </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) public LeafTlsReloadResult UpdateTlsConfig(string? newCertPath, string? newKeyPath)
{ {
var previousCert = CurrentCertPath; var previousCert = CurrentCertPath;
@@ -202,6 +213,15 @@ public sealed class LeafNodeManager : IAsyncDisposable
return new LeafTlsReloadResult(Changed: true, PreviousCertPath: previousCert, NewCertPath: newCertPath, Error: null); 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( public LeafNodeManager(
LeafNodeOptions options, LeafNodeOptions options,
ServerStats stats, ServerStats stats,
@@ -224,6 +244,10 @@ public sealed class LeafNodeManager : IAsyncDisposable
options.ImportSubjects); 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) public Task StartAsync(CancellationToken ct)
{ {
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -259,6 +283,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// it is propagated during the handshake. /// it is propagated during the handshake.
/// Go reference: leafnode.go — connectSolicited. /// Go reference: leafnode.go — connectSolicited.
/// </summary> /// </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) public async Task<LeafConnection> ConnectSolicitedAsync(string url, string? account, CancellationToken ct)
{ {
var endPoint = ParseEndpoint(url); 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) public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
{ {
// Apply subject filtering: outbound direction is hub→leaf (DenyExports). // 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); 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) public void PropagateLocalSubscription(string account, string subject, string? queue)
=> PropagateLocalSubscription(account, subject, queue, queueWeight: 0); => 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) public void PropagateLocalSubscription(string account, string subject, string? queue, int queueWeight)
{ {
// Subscription propagation is also subject to export filtering: // 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) public void PropagateLocalUnsubscription(string account, string subject, string? queue)
{ {
foreach (var connection in _connections.Values) foreach (var connection in _connections.Values)
@@ -342,6 +396,10 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// post-sync state. /// post-sync state.
/// Go reference: leafnode.go — sendPermsAndAccountInfo. /// Go reference: leafnode.go — sendPermsAndAccountInfo.
/// </summary> /// </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( public LeafPermSyncResult SendPermsAndAccountInfo(
string connectionId, string connectionId,
string? account, string? account,
@@ -375,6 +433,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// which subjects have local interest. /// which subjects have local interest.
/// Go reference: leafnode.go — initLeafNodeSmapAndSendSubs. /// Go reference: leafnode.go — initLeafNodeSmapAndSendSubs.
/// </summary> /// </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) public int InitLeafNodeSmapAndSendSubs(string connectionId, IEnumerable<string> subjects)
{ {
if (!_connections.TryGetValue(connectionId, out var connection)) 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. /// Returns the current permission-sync status for the specified connection.
/// Go reference: leafnode.go — sendPermsAndAccountInfo (read path). /// Go reference: leafnode.go — sendPermsAndAccountInfo (read path).
/// </summary> /// </summary>
/// <param name="connectionId">Connection identifier to query.</param>
public LeafPermSyncResult GetPermSyncStatus(string connectionId) public LeafPermSyncResult GetPermSyncStatus(string connectionId)
{ {
if (!_connections.TryGetValue(connectionId, out var connection)) 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. /// If another connection already uses the proposed domain, a conflict is reported.
/// Go reference: leafnode.go checkJetStreamMigrate. /// Go reference: leafnode.go checkJetStreamMigrate.
/// </summary> /// </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) public JetStreamMigrationResult CheckJetStreamMigrate(string connectionId, string? proposedDomain)
{ {
if (!_connections.TryGetValue(connectionId, out var connection)) 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. /// Returns true if any currently active connection is associated with the specified JetStream domain.
/// Go reference: leafnode.go — checkJetStreamMigrate domain-in-use check. /// Go reference: leafnode.go — checkJetStreamMigrate domain-in-use check.
/// </summary> /// </summary>
/// <param name="domain">JetStream domain to search for.</param>
public bool IsJetStreamDomainInUse(string domain) public bool IsJetStreamDomainInUse(string domain)
{ {
foreach (var conn in _connections.Values) 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. /// Returns false if a cluster with the same name is already registered.
/// Go reference: leafnode.go registerLeafNodeCluster. /// Go reference: leafnode.go registerLeafNodeCluster.
/// </summary> /// </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) public bool RegisterLeafNodeCluster(string clusterName, string gatewayUrl, int connectionCount)
{ {
var info = new LeafClusterInfo var info = new LeafClusterInfo
@@ -512,6 +579,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// Returns false if no entry with that name exists. /// Returns false if no entry with that name exists.
/// Go reference: leafnode.go — leaf cluster topology removal. /// Go reference: leafnode.go — leaf cluster topology removal.
/// </summary> /// </summary>
/// <param name="clusterName">Cluster name to remove.</param>
public bool UnregisterLeafNodeCluster(string clusterName) => public bool UnregisterLeafNodeCluster(string clusterName) =>
_leafClusters.TryRemove(clusterName, out _); _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. /// Returns true if a leaf cluster with the given name is currently registered.
/// Go reference: leafnode.go — leaf cluster topology lookup. /// Go reference: leafnode.go — leaf cluster topology lookup.
/// </summary> /// </summary>
/// <param name="clusterName">Cluster name to query.</param>
public bool HasLeafNodeCluster(string clusterName) => public bool HasLeafNodeCluster(string clusterName) =>
_leafClusters.ContainsKey(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. /// Returns the <see cref="LeafClusterInfo"/> for the named cluster, or null if not registered.
/// Go reference: leafnode.go — leaf cluster topology lookup. /// Go reference: leafnode.go — leaf cluster topology lookup.
/// </summary> /// </summary>
/// <param name="clusterName">Cluster name to query.</param>
public LeafClusterInfo? GetLeafNodeCluster(string clusterName) => public LeafClusterInfo? GetLeafNodeCluster(string clusterName) =>
_leafClusters.TryGetValue(clusterName, out var info) ? info : null; _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. /// No-op if the cluster is not registered.
/// Go reference: leafnode.go — leaf cluster connection count update. /// Go reference: leafnode.go — leaf cluster connection count update.
/// </summary> /// </summary>
/// <param name="clusterName">Cluster name to update.</param>
/// <param name="newCount">New connection count value.</param>
public void UpdateLeafClusterConnectionCount(string clusterName, int newCount) public void UpdateLeafClusterConnectionCount(string clusterName, int newCount)
{ {
if (_leafClusters.TryGetValue(clusterName, out var info)) 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. /// Injects a <see cref="LeafConnection"/> directly into the tracked connections.
/// For testing only — bypasses the normal handshake and registration path. /// For testing only — bypasses the normal handshake and registration path.
/// </summary> /// </summary>
/// <param name="connection">Connection to inject.</param>
internal void InjectConnectionForTesting(LeafConnection connection) internal void InjectConnectionForTesting(LeafConnection connection)
{ {
var key = $"{connection.RemoteId}:{connection.RemoteEndpoint}:{Guid.NewGuid():N}"; var key = $"{connection.RemoteId}:{connection.RemoteEndpoint}:{Guid.NewGuid():N}";
_connections.TryAdd(key, connection); _connections.TryAdd(key, connection);
} }
/// <summary>
/// Stops accept/reconnect loops and disposes all tracked leaf connections.
/// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_cts == null) if (_cts == null)
@@ -591,6 +667,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// Computes the next backoff delay using exponential backoff with a cap. /// Computes the next backoff delay using exponential backoff with a cap.
/// Delay sequence: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, ... /// Delay sequence: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, ...
/// </summary> /// </summary>
/// <param name="attempt">Zero-based retry attempt count.</param>
internal static TimeSpan ComputeBackoff(int attempt) internal static TimeSpan ComputeBackoff(int attempt)
{ {
if (attempt < 0) attempt = 0; if (attempt < 0) attempt = 0;
@@ -765,6 +842,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
/// Checks for self-connect, duplicate connections, and JetStream domain conflicts. /// Checks for self-connect, duplicate connections, and JetStream domain conflicts.
/// Go reference: leafnode.go addLeafNodeConnection — duplicate and domain checks. /// Go reference: leafnode.go addLeafNodeConnection — duplicate and domain checks.
/// </summary> /// </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) public LeafValidationResult ValidateRemoteLeafNode(string remoteId, string? account, string? jsDomain)
{ {
if (IsSelfConnect(remoteId)) 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). /// 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. /// Go reference: leafnode.go loop detection via server ID comparison.
/// </summary> /// </summary>
/// <param name="remoteId">Remote server identifier to compare.</param>
public bool IsSelfConnect(string remoteId) => string.Equals(remoteId, _serverId, StringComparison.Ordinal); public bool IsSelfConnect(string remoteId) => string.Equals(remoteId, _serverId, StringComparison.Ordinal);
/// <summary> /// <summary>
/// Returns true if any currently registered connection has the specified remote server ID. /// Returns true if any currently registered connection has the specified remote server ID.
/// </summary> /// </summary>
/// <param name="remoteId">Remote server identifier to look up.</param>
public bool HasConnection(string remoteId) => GetConnectionByRemoteId(remoteId) != null; public bool HasConnection(string remoteId) => GetConnectionByRemoteId(remoteId) != null;
/// <summary> /// <summary>
/// Returns the first registered connection whose RemoteId matches the given value, or null if none. /// Returns the first registered connection whose RemoteId matches the given value, or null if none.
/// </summary> /// </summary>
/// <param name="remoteId">Remote server identifier to resolve.</param>
public LeafConnection? GetConnectionByRemoteId(string remoteId) public LeafConnection? GetConnectionByRemoteId(string remoteId)
{ {
foreach (var conn in _connections.Values) foreach (var conn in _connections.Values)
@@ -942,8 +1025,23 @@ public enum JetStreamMigrationStatus
/// </summary> /// </summary>
public sealed class LeafClusterInfo public sealed class LeafClusterInfo
{ {
/// <summary>
/// Gets the logical cluster name.
/// </summary>
public required string ClusterName { get; init; } public required string ClusterName { get; init; }
/// <summary>
/// Gets the gateway URL associated with this cluster.
/// </summary>
public required string GatewayUrl { get; init; } public required string GatewayUrl { get; init; }
/// <summary>
/// Gets or sets the active connection count for this cluster.
/// </summary>
public int ConnectionCount { get; set; } public int ConnectionCount { get; set; }
/// <summary>
/// Gets when this cluster entry was registered.
/// </summary>
public DateTime RegisteredAt { get; init; } = DateTime.UtcNow; public DateTime RegisteredAt { get; init; } = DateTime.UtcNow;
} }
+115
View File
@@ -5,33 +5,148 @@ namespace NATS.Server.Monitoring;
/// </summary> /// </summary>
public sealed record ClosedClient public sealed record ClosedClient
{ {
/// <summary>
/// Server-assigned client identifier.
/// </summary>
public required ulong Cid { get; init; } public required ulong Cid { get; init; }
/// <summary>
/// Remote client IP address.
/// </summary>
public string Ip { get; init; } = ""; public string Ip { get; init; } = "";
/// <summary>
/// Remote client port.
/// </summary>
public int Port { get; init; } public int Port { get; init; }
/// <summary>
/// Connection start timestamp in UTC.
/// </summary>
public DateTime Start { get; init; } public DateTime Start { get; init; }
/// <summary>
/// Connection close timestamp in UTC.
/// </summary>
public DateTime Stop { get; init; } public DateTime Stop { get; init; }
/// <summary>
/// Close reason text.
/// </summary>
public string Reason { get; init; } = ""; public string Reason { get; init; } = "";
/// <summary>
/// Client-reported name from CONNECT options.
/// </summary>
public string Name { get; init; } = ""; public string Name { get; init; } = "";
/// <summary>
/// Client-reported language from CONNECT options.
/// </summary>
public string Lang { get; init; } = ""; public string Lang { get; init; } = "";
/// <summary>
/// Client-reported library version from CONNECT options.
/// </summary>
public string Version { get; init; } = ""; public string Version { get; init; } = "";
/// <summary>
/// Authorized user identity.
/// </summary>
public string AuthorizedUser { get; init; } = ""; public string AuthorizedUser { get; init; } = "";
/// <summary>
/// Account name used by the client.
/// </summary>
public string Account { get; init; } = ""; public string Account { get; init; } = "";
/// <summary>
/// Number of inbound messages received from the client.
/// </summary>
public long InMsgs { get; init; } public long InMsgs { get; init; }
/// <summary>
/// Number of outbound messages sent to the client.
/// </summary>
public long OutMsgs { get; init; } public long OutMsgs { get; init; }
/// <summary>
/// Number of inbound bytes received from the client.
/// </summary>
public long InBytes { get; init; } public long InBytes { get; init; }
/// <summary>
/// Number of outbound bytes sent to the client.
/// </summary>
public long OutBytes { get; init; } public long OutBytes { get; init; }
/// <summary>
/// Number of active subscriptions at close time.
/// </summary>
public uint NumSubs { get; init; } public uint NumSubs { get; init; }
/// <summary>
/// Last observed round-trip time.
/// </summary>
public TimeSpan Rtt { get; init; } public TimeSpan Rtt { get; init; }
/// <summary>
/// Negotiated TLS protocol version.
/// </summary>
public string TlsVersion { get; init; } = ""; public string TlsVersion { get; init; } = "";
/// <summary>
/// Negotiated TLS cipher suite.
/// </summary>
public string TlsCipherSuite { get; init; } = ""; public string TlsCipherSuite { get; init; } = "";
/// <summary>
/// Peer certificate subject.
/// </summary>
public string TlsPeerCertSubject { get; init; } = ""; public string TlsPeerCertSubject { get; init; } = "";
/// <summary>
/// SHA-256 hash of peer certificate public key subject.
/// </summary>
public string TlsPeerCertSubjectPkSha256 { get; init; } = ""; public string TlsPeerCertSubjectPkSha256 { get; init; } = "";
/// <summary>
/// SHA-256 fingerprint of peer certificate.
/// </summary>
public string TlsPeerCertSha256 { get; init; } = ""; public string TlsPeerCertSha256 { get; init; } = "";
/// <summary>
/// MQTT client identifier when the connection used MQTT.
/// </summary>
public string MqttClient { get; init; } = ""; public string MqttClient { get; init; } = "";
/// <summary>
/// Number of slow-consumer stalls observed.
/// </summary>
public long Stalls { get; init; } public long Stalls { get; init; }
/// <summary>
/// User JWT (if present) associated with the connection.
/// </summary>
public string Jwt { get; init; } = ""; public string Jwt { get; init; } = "";
/// <summary>
/// Issuer key for JWT-authenticated clients.
/// </summary>
public string IssuerKey { get; init; } = ""; public string IssuerKey { get; init; } = "";
/// <summary>
/// Human-readable name tag associated with the client.
/// </summary>
public string NameTag { get; init; } = ""; public string NameTag { get; init; } = "";
/// <summary>
/// Arbitrary tags associated with the client.
/// </summary>
public string[] Tags { get; init; } = []; public string[] Tags { get; init; } = [];
/// <summary>
/// Proxy key when the client was connected through a proxy.
/// </summary>
public string ProxyKey { get; init; } = ""; public string ProxyKey { get; init; } = "";
} }
+50
View File
@@ -5,9 +5,17 @@ namespace NATS.Server.Mqtt;
public static class MqttPacketWriter 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) public static byte[] WriteString(string value)
=> WriteBytes(Encoding.UTF8.GetBytes(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) public static byte[] WriteBytes(ReadOnlySpan<byte> bytes)
{ {
if (bytes.Length > ushort.MaxValue) if (bytes.Length > ushort.MaxValue)
@@ -19,6 +27,12 @@ public static class MqttPacketWriter
return buffer; 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) public static byte[] Write(MqttControlPacketType type, ReadOnlySpan<byte> payload, byte flags = 0)
{ {
if (type == MqttControlPacketType.Reserved) if (type == MqttControlPacketType.Reserved)
@@ -47,6 +61,7 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes a PUBACK packet (QoS 1 acknowledgment). /// Writes a PUBACK packet (QoS 1 acknowledgment).
/// </summary> /// </summary>
/// <param name="packetId">Packet identifier being acknowledged.</param>
public static byte[] WritePubAck(ushort packetId) public static byte[] WritePubAck(ushort packetId)
{ {
Span<byte> payload = stackalloc byte[2]; Span<byte> payload = stackalloc byte[2];
@@ -57,6 +72,8 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes a SUBACK packet with granted QoS values per subscription filter. /// Writes a SUBACK packet with granted QoS values per subscription filter.
/// </summary> /// </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) public static byte[] WriteSubAck(ushort packetId, ReadOnlySpan<byte> grantedQoS)
{ {
var payload = new byte[2 + grantedQoS.Length]; var payload = new byte[2 + grantedQoS.Length];
@@ -68,6 +85,7 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes an UNSUBACK packet. /// Writes an UNSUBACK packet.
/// </summary> /// </summary>
/// <param name="packetId">UNSUBSCRIBE packet identifier.</param>
public static byte[] WriteUnsubAck(ushort packetId) public static byte[] WriteUnsubAck(ushort packetId)
{ {
Span<byte> payload = stackalloc byte[2]; Span<byte> payload = stackalloc byte[2];
@@ -84,6 +102,7 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes a PUBREC packet (QoS 2 step 1 response). /// Writes a PUBREC packet (QoS 2 step 1 response).
/// </summary> /// </summary>
/// <param name="packetId">PUBLISH packet identifier.</param>
public static byte[] WritePubRec(ushort packetId) public static byte[] WritePubRec(ushort packetId)
{ {
Span<byte> payload = stackalloc byte[2]; Span<byte> payload = stackalloc byte[2];
@@ -94,6 +113,7 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes a PUBREL packet (QoS 2 step 2). Fixed-header flags must be 0x02 per MQTT spec. /// Writes a PUBREL packet (QoS 2 step 2). Fixed-header flags must be 0x02 per MQTT spec.
/// </summary> /// </summary>
/// <param name="packetId">PUBLISH packet identifier.</param>
public static byte[] WritePubRel(ushort packetId) public static byte[] WritePubRel(ushort packetId)
{ {
Span<byte> payload = stackalloc byte[2]; Span<byte> payload = stackalloc byte[2];
@@ -104,6 +124,7 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes a PUBCOMP packet (QoS 2 step 3 response). /// Writes a PUBCOMP packet (QoS 2 step 3 response).
/// </summary> /// </summary>
/// <param name="packetId">PUBLISH packet identifier.</param>
public static byte[] WritePubComp(ushort packetId) public static byte[] WritePubComp(ushort packetId)
{ {
Span<byte> payload = stackalloc byte[2]; Span<byte> payload = stackalloc byte[2];
@@ -114,6 +135,12 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Writes an MQTT PUBLISH packet for delivery to a client. /// Writes an MQTT PUBLISH packet for delivery to a client.
/// </summary> /// </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, public static byte[] WritePublish(string topic, ReadOnlySpan<byte> payload, byte qos = 0,
bool retain = false, bool dup = false, ushort packetId = 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. /// Writes a complete MQTT PUBLISH packet directly into a destination span.
/// Returns the number of bytes written. Zero-allocation hot path for message delivery. /// Returns the number of bytes written. Zero-allocation hot path for message delivery.
/// </summary> /// </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, 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) ReadOnlySpan<byte> payload, byte qos = 0, bool retain = false, bool dup = false, ushort packetId = 0)
{ {
@@ -198,6 +232,9 @@ public static class MqttPacketWriter
/// <summary> /// <summary>
/// Calculates the total wire size of a PUBLISH packet without writing it. /// Calculates the total wire size of a PUBLISH packet without writing it.
/// </summary> /// </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) public static int MeasurePublish(int topicLen, int payloadLen, byte qos)
{ {
var remainingLength = 2 + topicLen + (qos > 0 ? 2 : 0) + payloadLen; var remainingLength = 2 + topicLen + (qos > 0 ? 2 : 0) + payloadLen;
@@ -205,6 +242,11 @@ public static class MqttPacketWriter
return 1 + rlLen + remainingLength; 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) internal static int EncodeRemainingLengthTo(Span<byte> dest, int value)
{ {
var index = 0; var index = 0;
@@ -220,6 +262,10 @@ public static class MqttPacketWriter
return index; 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) internal static int MeasureRemainingLength(int value)
{ {
var count = 0; var count = 0;
@@ -232,6 +278,10 @@ public static class MqttPacketWriter
return count; 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) internal static byte[] EncodeRemainingLength(int value)
{ {
if (value < 0 || value > MqttProtocolConstants.MaxPayloadSize) if (value < 0 || value > MqttProtocolConstants.MaxPayloadSize)
+206
View File
@@ -20,15 +20,41 @@ namespace NATS.Server;
public interface IMessageRouter 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, void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, INatsClient sender); 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); 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); 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); void PublishDisconnectEvent(INatsClient client);
} }
public interface ISubListAccess public interface ISubListAccess
{ {
/// <summary>
/// Gets the server's global subscription index.
/// </summary>
SubList SubList { get; } SubList SubList { get; }
} }
@@ -41,12 +67,20 @@ internal readonly struct OutboundData
public readonly ReadOnlyMemory<byte> Data; public readonly ReadOnlyMemory<byte> Data;
public readonly byte[]? PoolBuffer; 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) public OutboundData(ReadOnlyMemory<byte> data, byte[]? poolBuffer = null)
{ {
Data = data; Data = data;
PoolBuffer = poolBuffer; PoolBuffer = poolBuffer;
} }
/// <summary>
/// Gets the byte length of the outbound payload.
/// </summary>
public int Length => Data.Length; public int Length => Data.Length;
} }
@@ -90,12 +124,39 @@ public sealed class NatsClient : INatsClient, IDisposable
private ClientPermissions? _permissions; private ClientPermissions? _permissions;
private readonly ServerStats _serverStats; private readonly ServerStats _serverStats;
/// <summary>
/// Gets the server-assigned client identifier.
/// </summary>
public ulong Id { get; } public ulong Id { get; }
/// <summary>
/// Gets the connection kind (client/router/gateway/leaf/system).
/// </summary>
public ClientKind Kind { get; } public ClientKind Kind { get; }
/// <summary>
/// Gets parsed CONNECT options sent by the client.
/// </summary>
public ClientOptions? ClientOpts { get; private set; } public ClientOptions? ClientOpts { get; private set; }
/// <summary>
/// Gets tracing metadata propagated from CONNECT options.
/// </summary>
public MessageTraceContext TraceContext { get; private set; } = MessageTraceContext.Empty; 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; } public IMessageRouter? Router { get; set; }
/// <summary>
/// Gets the authenticated account assigned to this client.
/// </summary>
public Account? Account { get; private set; } public Account? Account { get; private set; }
/// <summary>
/// Gets the permission evaluator resolved during authentication.
/// </summary>
public ClientPermissions? Permissions => _permissions; public ClientPermissions? Permissions => _permissions;
/// <summary> /// <summary>
@@ -105,9 +166,20 @@ public sealed class NatsClient : INatsClient, IDisposable
public string? MqttClientId { get; set; } public string? MqttClientId { get; set; }
private readonly ClientFlagHolder _flags = new(); private readonly ClientFlagHolder _flags = new();
/// <summary>
/// Gets whether a valid CONNECT command has been processed.
/// </summary>
public bool ConnectReceived => _flags.HasFlag(ClientFlags.ConnectReceived); public bool ConnectReceived => _flags.HasFlag(ClientFlags.ConnectReceived);
/// <summary>
/// Gets the first close reason recorded for this connection.
/// </summary>
public ClientClosedReason CloseReason { get; private set; } 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) public void SetTraceMode(bool enabled)
{ {
if (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; } public DateTime StartTime { get; }
private long _lastActivityTicks; private long _lastActivityTicks;
/// <summary>
/// Gets the timestamp of the last observed client activity.
/// </summary>
public DateTime LastActivity => new(Interlocked.Read(ref _lastActivityTicks), DateTimeKind.Utc); public DateTime LastActivity => new(Interlocked.Read(ref _lastActivityTicks), DateTimeKind.Utc);
/// <summary>
/// Gets the remote client IP address, when available.
/// </summary>
public string? RemoteIp { get; } public string? RemoteIp { get; }
/// <summary>
/// Gets the remote client TCP port, when available.
/// </summary>
public int RemotePort { get; } public int RemotePort { get; }
// Stats // Stats
@@ -140,6 +226,9 @@ public sealed class NatsClient : INatsClient, IDisposable
// Close reason tracking // Close reason tracking
private int _skipFlushOnClose; 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; public bool ShouldSkipFlush => Volatile.Read(ref _skipFlushOnClose) != 0;
// PING keepalive state // PING keepalive state
@@ -149,18 +238,59 @@ public sealed class NatsClient : INatsClient, IDisposable
// RTT tracking // RTT tracking
private long _rttStartTicks; private long _rttStartTicks;
private long _rtt; private long _rtt;
/// <summary>
/// Gets the most recent round-trip time measured from PING/PONG.
/// </summary>
public TimeSpan Rtt => new(Interlocked.Read(ref _rtt)); 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; } public bool IsMqtt { get; set; }
/// <summary>
/// Gets or sets whether this connection is a WebSocket transport.
/// </summary>
public bool IsWebSocket { get; set; } public bool IsWebSocket { get; set; }
/// <summary>
/// Gets or sets captured WebSocket upgrade metadata.
/// </summary>
public WsUpgradeResult? WsInfo { get; set; } public WsUpgradeResult? WsInfo { get; set; }
/// <summary>
/// Gets or sets TLS session state captured for this connection.
/// </summary>
public TlsConnectionState? TlsState { get; set; } public TlsConnectionState? TlsState { get; set; }
/// <summary>
/// Gets or sets whether INFO has already been transmitted for this connection.
/// </summary>
public bool InfoAlreadySent { get; set; } public bool InfoAlreadySent { get; set; }
/// <summary>
/// Gets the active subscriptions owned by this client, keyed by SID.
/// </summary>
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs; public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
/// <summary>
/// Gets the last JetStream publish acknowledgement observed for this client.
/// </summary>
public PubAck? LastJetStreamPubAck { get; private set; } 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, public NatsClient(ulong id, Stream stream, Socket socket, NatsOptions options, ServerInfo serverInfo,
AuthService authService, byte[]? nonce, ILogger logger, ServerStats serverStats, AuthService authService, byte[]? nonce, ILogger logger, ServerStats serverStats,
ClientKind kind = ClientKind.Client) 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(); public byte[]? GetNonce() => _nonce?.ToArray();
/// <summary>
/// Returns the client-provided name from CONNECT options.
/// </summary>
public string GetName() => ClientOpts?.Name ?? string.Empty; public string GetName() => ClientOpts?.Name ?? string.Empty;
/// <summary>
/// Returns the external connection type used by monitoring endpoints.
/// </summary>
public ClientConnectionType ClientType() public ClientConnectionType ClientType()
{ {
if (Kind != ClientKind.Client) if (Kind != ClientKind.Client)
@@ -201,12 +340,19 @@ public sealed class NatsClient : INatsClient, IDisposable
return ClientConnectionType.Nats; return ClientConnectionType.Nats;
} }
/// <summary>
/// Returns a compact connection identity string for diagnostics.
/// </summary>
public override string ToString() public override string ToString()
{ {
var endpoint = RemoteIp is null ? "unknown" : $"{RemoteIp}:{RemotePort}"; var endpoint = RemoteIp is null ? "unknown" : $"{RemoteIp}:{RemotePort}";
return $"{Kind} cid={Id} endpoint={endpoint}"; 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)); public bool QueueOutbound(ReadOnlyMemory<byte> data) => QueueOutboundCore(new OutboundData(data));
/// <summary> /// <summary>
@@ -256,6 +402,9 @@ public sealed class NatsClient : INatsClient, IDisposable
return true; return true;
} }
/// <summary>
/// Gets queued outbound bytes awaiting write-loop flush.
/// </summary>
public long PendingBytes => Interlocked.Read(ref _pendingBytes); public long PendingBytes => Interlocked.Read(ref _pendingBytes);
/// <summary> /// <summary>
@@ -301,6 +450,10 @@ public sealed class NatsClient : INatsClient, IDisposable
/// </summary> /// </summary>
public bool ShouldCoalesceFlush => FlushSignalsPending < MaxFlushPending; 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) public async Task RunAsync(CancellationToken ct)
{ {
_clientCts = CancellationTokenSource.CreateLinkedTokenSource(ct); _clientCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -768,6 +921,10 @@ public sealed class NatsClient : INatsClient, IDisposable
Router?.ProcessMessage(subject, replyTo, headers, payload, this); 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) public void RecordJetStreamPubAck(PubAck ack)
{ {
LastJetStreamPubAck = 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, public void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload) 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. /// Callers must call <see cref="SignalFlush"/> after all messages in a batch are queued.
/// Go reference: client.go addToPCD — deferred flush via pcd map. /// Go reference: client.go addToPCD — deferred flush via pcd map.
/// </summary> /// </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, public void SendMessageNoFlush(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload) 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 /// Fast-path overload accepting pre-encoded subject and SID bytes to avoid
/// per-delivery ASCII encoding in fan-out scenarios. /// per-delivery ASCII encoding in fan-out scenarios.
/// </summary> /// </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, public void SendMessageNoFlush(ReadOnlySpan<byte> subjectBytes, ReadOnlySpan<byte> sidBytes, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload) 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. /// (" [reply] sizes\r\n") once per publish. Only the SID varies per delivery.
/// Eliminates per-delivery replyTo encoding, size formatting, and prefix/subject copying. /// Eliminates per-delivery replyTo encoding, size formatting, and prefix/subject copying.
/// </summary> /// </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, public void SendMessagePreformatted(ReadOnlySpan<byte> prefix, ReadOnlySpan<byte> sidBytes,
ReadOnlySpan<byte> suffix, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload) ReadOnlySpan<byte> suffix, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{ {
@@ -1072,6 +1252,10 @@ public sealed class NatsClient : INatsClient, IDisposable
QueueOutbound(data); 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) public void SendErr(string message)
{ {
var errLine = Encoding.ASCII.GetBytes($"-ERR '{message}'\r\n"); 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) public async Task SendErrAndCloseAsync(string message, ClientClosedReason reason = ClientClosedReason.ProtocolViolation)
{ {
await CloseWithReasonAsync(reason, message); await CloseWithReasonAsync(reason, message);
@@ -1303,6 +1492,7 @@ public sealed class NatsClient : INatsClient, IDisposable
/// Sets skip-flush flag for error-related reasons. /// Sets skip-flush flag for error-related reasons.
/// Only the first call sets the reason (subsequent calls are no-ops). /// Only the first call sets the reason (subsequent calls are no-ops).
/// </summary> /// </summary>
/// <param name="reason">Close reason to record.</param>
public void MarkClosed(ClientClosedReason reason) public void MarkClosed(ClientClosedReason reason)
{ {
if (CloseReason != ClientClosedReason.None) if (CloseReason != ClientClosedReason.None)
@@ -1327,6 +1517,7 @@ public sealed class NatsClient : INatsClient, IDisposable
/// <summary> /// <summary>
/// Flushes pending data (unless skip-flush is set) and closes the connection. /// Flushes pending data (unless skip-flush is set) and closes the connection.
/// </summary> /// </summary>
/// <param name="minimalFlush">Whether to use a shorter best-effort flush window before close.</param>
public async Task FlushAndCloseAsync(bool minimalFlush = false) public async Task FlushAndCloseAsync(bool minimalFlush = false)
{ {
if (!ShouldSkipFlush) if (!ShouldSkipFlush)
@@ -1349,12 +1540,20 @@ public sealed class NatsClient : INatsClient, IDisposable
catch (ObjectDisposedException) { } 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) public void RemoveSubscription(string sid)
{ {
if (_subs.Remove(sid)) if (_subs.Remove(sid))
Account?.DecrementSubscriptions(); 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) public void RemoveAllSubscriptions(SubList subList)
{ {
foreach (var sub in _subs.Values) foreach (var sub in _subs.Values)
@@ -1362,6 +1561,9 @@ public sealed class NatsClient : INatsClient, IDisposable
_subs.Clear(); _subs.Clear();
} }
/// <summary>
/// Disposes connection resources and completes outbound channels.
/// </summary>
public void Dispose() public void Dispose()
{ {
_permissions?.Dispose(); _permissions?.Dispose();
@@ -1390,6 +1592,7 @@ public sealed class NatsClient : INatsClient, IDisposable
/// Go reference: server/client.go — routes/gateways/leafnodes get TcpFlush, /// Go reference: server/client.go — routes/gateways/leafnodes get TcpFlush,
/// regular clients get Close. /// regular clients get Close.
/// </summary> /// </summary>
/// <param name="kind">Connection kind to evaluate.</param>
public static WriteTimeoutPolicy GetWriteTimeoutPolicy(ClientKind kind) => kind switch public static WriteTimeoutPolicy GetWriteTimeoutPolicy(ClientKind kind) => kind switch
{ {
ClientKind.Client => WriteTimeoutPolicy.Close, ClientKind.Client => WriteTimeoutPolicy.Close,
@@ -1429,6 +1632,7 @@ public sealed class NatsClient : INatsClient, IDisposable
/// The stall threshold is set at 75% of maxPending. /// The stall threshold is set at 75% of maxPending.
/// Go reference: server/client.go stc channel creation. /// Go reference: server/client.go stc channel creation.
/// </summary> /// </summary>
/// <param name="maxPending">Maximum pending bytes configured for the client.</param>
public StallGate(long maxPending) public StallGate(long maxPending)
{ {
_threshold = maxPending * 3 / 4; _threshold = maxPending * 3 / 4;
@@ -1444,6 +1648,7 @@ public sealed class NatsClient : INatsClient, IDisposable
/// Updates pending byte count and activates/deactivates the stall gate. /// Updates pending byte count and activates/deactivates the stall gate.
/// Go reference: server/client.go stalledRoute check. /// Go reference: server/client.go stalledRoute check.
/// </summary> /// </summary>
/// <param name="pending">Current pending outbound byte count.</param>
public void UpdatePending(long pending) public void UpdatePending(long pending)
{ {
lock (_gate) 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). /// false if timed out (indicating the client should be closed as slow consumer).
/// Go reference: server/client.go stc channel receive with timeout. /// Go reference: server/client.go stc channel receive with timeout.
/// </summary> /// </summary>
/// <param name="timeout">Maximum duration to wait for stall release.</param>
public async Task<bool> WaitAsync(TimeSpan timeout) public async Task<bool> WaitAsync(TimeSpan timeout)
{ {
SemaphoreSlim? sem; SemaphoreSlim? sem;
+336
View File
@@ -69,6 +69,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// via InternalsVisibleTo. /// via InternalsVisibleTo.
/// </summary> /// </summary>
internal RouteManager? RouteManager => _routeManager; internal RouteManager? RouteManager => _routeManager;
/// <summary>
/// Exposes the gateway manager for gateway topology and interest propagation tests.
/// </summary>
internal GatewayManager? GatewayManager => _gatewayManager; internal GatewayManager? GatewayManager => _gatewayManager;
private readonly GatewayManager? _gatewayManager; private readonly GatewayManager? _gatewayManager;
private readonly LeafNodeManager? _leafNodeManager; private readonly LeafNodeManager? _leafNodeManager;
@@ -109,13 +113,44 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, 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; public SubList SubList => _globalAccount.SubList;
/// <summary>
/// Gets the cached `INFO` protocol line broadcast to connected clients.
/// </summary>
public byte[] CachedInfoLine => _cachedInfoLine; public byte[] CachedInfoLine => _cachedInfoLine;
/// <summary>
/// Gets runtime counters for client, route, and message activity.
/// </summary>
public ServerStats Stats => _stats; 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); 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; public string ServerId => _serverInfo.ServerId;
/// <summary>
/// Gets the human-readable server name used in monitoring and advisories.
/// </summary>
public string ServerName => _serverInfo.ServerName; public string ServerName => _serverInfo.ServerName;
/// <summary>
/// Gets the current number of tracked client connections.
/// </summary>
public int ClientCount => _clients.Count; public int ClientCount => _clients.Count;
/// <summary>
/// Gets the TCP client listen port for the NATS protocol endpoint.
/// </summary>
public int Port => _options.Port; public int Port => _options.Port;
/// <summary> /// <summary>
@@ -130,25 +165,95 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public IEnumerable<Mqtt.MqttNatsClientAdapter> GetMqttAdapters() public IEnumerable<Mqtt.MqttNatsClientAdapter> GetMqttAdapters()
=> _mqttListener?.GetMqttAdapters() ?? []; => _mqttListener?.GetMqttAdapters() ?? [];
/// <summary>
/// Gets the system account used for `$SYS` advisories and internal control traffic.
/// </summary>
public Account SystemAccount => _systemAccount; public Account SystemAccount => _systemAccount;
/// <summary>
/// Gets this server's public NKey identity.
/// </summary>
public string ServerNKey { get; } public string ServerNKey { get; }
/// <summary>
/// Gets the internal event system used to publish server advisories.
/// </summary>
public InternalEventSystem? EventSystem => _eventSystem; public InternalEventSystem? EventSystem => _eventSystem;
/// <summary>
/// Gets whether shutdown has started and connection lifecycle is draining.
/// </summary>
public bool IsShuttingDown => Volatile.Read(ref _shutdown) != 0; 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; 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; public string? ClusterListen => _routeManager?.ListenEndpoint;
/// <summary>
/// Gets the active gateway listen endpoint, if gateways are enabled.
/// </summary>
public string? GatewayListen => _gatewayManager?.ListenEndpoint; public string? GatewayListen => _gatewayManager?.ListenEndpoint;
/// <summary>
/// Gets the active leaf node listen endpoint, if leaf links are enabled.
/// </summary>
public string? LeafListen => _leafNodeManager?.ListenEndpoint; public string? LeafListen => _leafNodeManager?.ListenEndpoint;
/// <summary>
/// Gets whether profiling is configured for this server process.
/// </summary>
public bool IsProfilingEnabled => _options.ProfPort > 0; public bool IsProfilingEnabled => _options.ProfPort > 0;
/// <summary>
/// Gets the internal JetStream control client used for system-side operations.
/// </summary>
public InternalClient? JetStreamInternalClient => _jetStreamInternalClient; public InternalClient? JetStreamInternalClient => _jetStreamInternalClient;
/// <summary>
/// Gets the JetStream API router used for `$JS.API.*` request handling.
/// </summary>
public JetStreamApiRouter? JetStreamApiRouter => _jetStreamApiRouter; 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; public int JetStreamStreams => _jetStreamStreamManager?.StreamNames.Count ?? 0;
/// <summary>
/// Gets the number of active JetStream consumers currently tracked.
/// </summary>
public int JetStreamConsumers => _jetStreamConsumerManager?.ConsumerCount ?? 0; 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; } public Action? ReOpenLogFile { get; set; }
/// <summary>
/// Returns all currently tracked client connections.
/// </summary>
public IEnumerable<NatsClient> GetClients() => _clients.Values; public IEnumerable<NatsClient> GetClients() => _clients.Values;
/// <summary>
/// Returns the configured cluster name when clustering is enabled.
/// </summary>
public string? ClusterName() => _options.Cluster?.Name; public string? ClusterName() => _options.Cluster?.Name;
/// <summary>
/// Returns connected peer server IDs from the current route topology snapshot.
/// </summary>
public IReadOnlyList<string> ActivePeers() public IReadOnlyList<string> ActivePeers()
=> _routeManager?.BuildTopologySnapshot().ConnectedServerIds ?? []; => _routeManager?.BuildTopologySnapshot().ConnectedServerIds ?? [];
/// <summary>
/// Starts profiler exposure when configured; currently reports unsupported status.
/// </summary>
public bool StartProfiler() public bool StartProfiler()
{ {
if (_options.ProfPort <= 0) if (_options.ProfPort <= 0)
@@ -158,12 +263,23 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return true; 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) public bool DisconnectClientByID(ulong clientId)
=> CloseClientById(clientId, minimalFlush: true); => 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) public bool LDMClientByID(ulong clientId)
=> CloseClientById(clientId, minimalFlush: false); => CloseClientById(clientId, minimalFlush: false);
/// <summary>
/// Builds the server endpoints payload used by startup tooling and orchestration.
/// </summary>
public Ports PortsInfo() public Ports PortsInfo()
{ {
var ports = new Ports(); var ports = new Ports();
@@ -189,6 +305,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return ports; return ports;
} }
/// <summary>
/// Returns advertised client connect URLs used by cluster peers and clients.
/// </summary>
public IReadOnlyList<string> GetConnectURLs() public IReadOnlyList<string> GetConnectURLs()
{ {
if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise)) if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise))
@@ -202,6 +321,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return result; return result;
} }
/// <summary>
/// Rebuilds the advertised INFO payload and pushes it to connected clients.
/// </summary>
public void UpdateServerINFOAndSendINFOToClients() public void UpdateServerINFOAndSendINFOToClients()
{ {
_serverInfo.ConnectUrls = [.. GetConnectURLs()]; _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() public string ClientURL()
{ {
if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise)) if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise))
@@ -223,6 +348,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return $"nats://{host}:{_options.Port}"; return $"nats://{host}:{_options.Port}";
} }
/// <summary>
/// Returns the primary WebSocket URL when WebSocket transport is enabled.
/// </summary>
public string? WebsocketURL() public string? WebsocketURL()
{ {
if (_options.WebSocket.Port < 0) if (_options.WebSocket.Port < 0)
@@ -239,18 +367,45 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
return $"{wsScheme}://{wsHost}:{_options.WebSocket.Port}"; return $"{wsScheme}://{wsHost}:{_options.WebSocket.Port}";
} }
/// <summary>
/// Returns the active route connection count.
/// </summary>
public int NumRoutes() => (int)Interlocked.Read(ref _stats.Routes); public int NumRoutes() => (int)Interlocked.Read(ref _stats.Routes);
/// <summary>
/// Returns total remote links across routes, gateways, and leaf nodes.
/// </summary>
public int NumRemotes() public int NumRemotes()
=> (int)(Interlocked.Read(ref _stats.Routes) + Interlocked.Read(ref _stats.Gateways) + Interlocked.Read(ref _stats.Leafs)); => (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); public int NumLeafNodes() => (int)Interlocked.Read(ref _stats.Leafs);
/// <summary>
/// Returns the number of outbound gateway connections.
/// </summary>
public int NumOutboundGateways() => _gatewayManager?.NumOutboundGateways() ?? 0; public int NumOutboundGateways() => _gatewayManager?.NumOutboundGateways() ?? 0;
/// <summary>
/// Returns the number of inbound gateway connections.
/// </summary>
public int NumInboundGateways() => _gatewayManager?.NumInboundGateways() ?? 0; 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); 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; public bool JetStreamEnabled() => _jetStreamService?.IsRunning ?? false;
/// <summary>
/// Returns a snapshot of configured JetStream limits and storage settings.
/// </summary>
public JetStreamOptions? JetStreamConfig() public JetStreamOptions? JetStreamConfig()
{ {
if (_options.JetStream is null) 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; public string StoreDir() => _options.JetStream?.StoreDir ?? string.Empty;
/// <summary>
/// Returns when the active configuration snapshot was last updated.
/// </summary>
public DateTime ConfigTime() => _configTime; public DateTime ConfigTime() => _configTime;
/// <summary>
/// Returns the primary client listen address as `host:port`.
/// </summary>
public string Addr() => $"{_options.Host}:{_options.Port}"; public string Addr() => $"{_options.Host}:{_options.Port}";
/// <summary>
/// Returns the monitoring listen address when monitoring is enabled.
/// </summary>
public string? MonitorAddr() public string? MonitorAddr()
=> _options.MonitorPort > 0 => _options.MonitorPort > 0
? $"{_options.MonitorHost}:{_options.MonitorPort}" ? $"{_options.MonitorHost}:{_options.MonitorPort}"
: null; : null;
/// <summary>
/// Returns the configured cluster listen endpoint.
/// </summary>
public string? ClusterAddr() => _routeManager?.ListenEndpoint; public string? ClusterAddr() => _routeManager?.ListenEndpoint;
/// <summary>
/// Returns the configured gateway listen endpoint.
/// </summary>
public string? GatewayAddr() => _gatewayManager?.ListenEndpoint; public string? GatewayAddr() => _gatewayManager?.ListenEndpoint;
/// <summary>
/// Returns the gateway URL used for external discovery.
/// </summary>
public string? GetGatewayURL() => _gatewayManager?.ListenEndpoint; public string? GetGatewayURL() => _gatewayManager?.ListenEndpoint;
/// <summary>
/// Returns the configured gateway name for cross-cluster identity.
/// </summary>
public string? GetGatewayName() => _options.Gateway?.Name; public string? GetGatewayName() => _options.Gateway?.Name;
/// <summary>
/// Returns the profiler address when profiling is enabled.
/// </summary>
public string? ProfilerAddr() public string? ProfilerAddr()
=> _options.ProfPort > 0 => _options.ProfPort > 0
? $"{_options.Host}:{_options.ProfPort}" ? $"{_options.Host}:{_options.ProfPort}"
: null; : null;
/// <summary>
/// Returns the count of accounts currently serving at least one client.
/// </summary>
public int NumActiveAccounts() => _accounts.Values.Count(acc => acc.ClientCount > 0); public int NumActiveAccounts() => _accounts.Values.Count(acc => acc.ClientCount > 0);
/// <summary>
/// Returns the total number of loaded accounts.
/// </summary>
public int NumLoadedAccounts() => _accounts.Count; public int NumLoadedAccounts() => _accounts.Count;
/// <summary>
/// Returns the closed-client ring buffer snapshot for monitoring endpoints.
/// </summary>
public IReadOnlyList<ClosedClient> GetClosedClients() => _closedClients.GetAll(); public IReadOnlyList<ClosedClient> GetClosedClients() => _closedClients.GetAll();
/// <summary>
/// Returns all known accounts currently loaded in this server.
/// </summary>
public IEnumerable<Auth.Account> GetAccounts() => _accounts.Values; 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); 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) public bool HasRemoteInterest(string account, string subject)
=> GetOrCreateAccount(account).SubList.HasRemoteInterest(account, 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) public bool TryCaptureJetStreamPublish(string subject, ReadOnlyMemory<byte> payload, out PubAck ack)
{ {
if (_jetStreamPublisher != null && _jetStreamPublisher.TryCapture(subject, payload, out 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; public Task WaitForReadyAsync() => _listeningStarted.Task;
/// <summary>
/// Blocks until server shutdown has completed.
/// </summary>
public void WaitForShutdown() => _shutdownComplete.Task.GetAwaiter().GetResult(); public void WaitForShutdown() => _shutdownComplete.Task.GetAwaiter().GetResult();
/// <summary>
/// Exposes the active TLS certificate provider for integration tests.
/// </summary>
internal TlsCertificateProvider? TlsCertProviderForTest => _tlsCertProvider; internal TlsCertificateProvider? TlsCertProviderForTest => _tlsCertProvider;
/// <summary>
/// Acquires the config reload lock for deterministic test coordination.
/// </summary>
internal Task AcquireReloadLockForTestAsync() => _reloadMu.WaitAsync(); internal Task AcquireReloadLockForTestAsync() => _reloadMu.WaitAsync();
/// <summary>
/// Releases the config reload lock previously acquired by tests.
/// </summary>
internal void ReleaseReloadLockForTest() => _reloadMu.Release(); 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; 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) => internal void NotifyAcceptErrorForTest(Exception ex, EndPoint? endpoint, TimeSpan delay) =>
_acceptLoopErrorHandler?.OnAcceptError(ex, endpoint, delay); _acceptLoopErrorHandler?.OnAcceptError(ex, endpoint, delay);
/// <summary>
/// Gracefully shuts down listeners, internal services, and active clients.
/// </summary>
public async Task ShutdownAsync() public async Task ShutdownAsync()
{ {
if (Interlocked.CompareExchange(ref _shutdown, 1, 0) != 0) if (Interlocked.CompareExchange(ref _shutdown, 1, 0) != 0)
@@ -500,6 +744,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_shutdownComplete.TrySetResult(); _shutdownComplete.TrySetResult();
} }
/// <summary>
/// Starts lame-duck mode, drains existing clients, then performs full shutdown.
/// </summary>
public async Task LameDuckShutdownAsync() public async Task LameDuckShutdownAsync()
{ {
if (IsShuttingDown || Interlocked.CompareExchange(ref _lameDuck, 1, 0) != 0) 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) public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
{ {
_options = options; _options = options;
@@ -790,6 +1042,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private static bool IsWildcardHost(string host) private static bool IsWildcardHost(string host)
=> host == "0.0.0.0" || 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) internal static IReadOnlyList<string> GetNonLocalIPsIfHostIsIPAny(string host)
{ {
if (!IsWildcardHost(host)) if (!IsWildcardHost(host))
@@ -852,6 +1109,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
targets.Add(endpoint); 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) public async Task StartAsync(CancellationToken ct)
{ {
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _quitCts.Token); 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) public void OnLocalSubscription(string account, string subject, string? queue)
{ {
_routeManager?.PropagateLocalSubscription(account, subject, queue); _routeManager?.PropagateLocalSubscription(account, subject, queue);
@@ -1259,6 +1526,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_leafNodeManager?.PropagateLocalSubscription(account, subject, queue); _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) public void OnLocalUnsubscription(string account, string subject, string? queue)
{ {
_routeManager?.PropagateLocalUnsubscription(account, subject, 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, public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, INatsClient sender) ReadOnlyMemory<byte> payload, INatsClient sender)
{ {
@@ -1998,6 +2279,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// subscribers in the destination account. /// subscribers in the destination account.
/// Reference: Go server/accounts.go addServiceImport / processServiceImport. /// Reference: Go server/accounts.go addServiceImport / processServiceImport.
/// </summary> /// </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, public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, Account? sourceAccount = null) 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. /// it checks the account's Imports.Services.
/// Reference: Go server/accounts.go addServiceImportSub. /// Reference: Go server/accounts.go addServiceImportSub.
/// </summary> /// </summary>
/// <param name="account">Importer account whose configured service imports are being wired.</param>
public void WireServiceImports(Account account) public void WireServiceImports(Account account)
{ {
foreach (var kvp in account.Imports.Services) foreach (var kvp in account.Imports.Services)
@@ -2217,6 +2505,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
sender.QueueOutbound(msg); 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) public Account GetOrCreateAccount(string name)
{ {
return _accounts.GetOrAdd(name, n => 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. /// Returns true if the subject belongs to the $SYS subject space.
/// Reference: Go server/server.go — isReservedSubject. /// Reference: Go server/server.go — isReservedSubject.
/// </summary> /// </summary>
/// <param name="subject">Subject string to evaluate.</param>
public static bool IsSystemSubject(string subject) public static bool IsSystemSubject(string subject)
=> subject.StartsWith("$SYS.", StringComparison.Ordinal) || subject == "$SYS"; => 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. /// Non-system accounts cannot subscribe to $SYS.> subjects.
/// Reference: Go server/accounts.go — isReservedForSys. /// Reference: Go server/accounts.go — isReservedForSys.
/// </summary> /// </summary>
/// <param name="account">Account requesting the subscription.</param>
/// <param name="subject">Subject pattern being subscribed.</param>
public bool IsSubscriptionAllowed(Account? account, string subject) public bool IsSubscriptionAllowed(Account? account, string subject)
{ {
if (!IsSystemSubject(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. /// for $SYS.> subjects, or the provided account's SubList for everything else.
/// Reference: Go server/server.go — sublist routing for internal subjects. /// Reference: Go server/server.go — sublist routing for internal subjects.
/// </summary> /// </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) public SubList GetSubListForSubject(Account? account, string subject)
{ {
if (IsSystemSubject(subject)) if (IsSystemSubject(subject))
@@ -2331,11 +2628,23 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
SendInternalMsg(latency.Subject, reply: null, msg); 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) public void SendInternalMsg(string subject, string? reply, object? msg)
{ {
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = 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) public void SendInternalAccountMsg(Account account, string subject, object? msg)
{ {
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Body = 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. /// Handles $SYS.REQ.SERVER.{id}.VARZ requests.
/// Returns core server information including stats counters. /// Returns core server information including stats counters.
/// </summary> /// </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) public void HandleVarzRequest(string subject, string? reply)
{ {
if (reply == null) return; if (reply == null) return;
@@ -2370,6 +2681,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Handles $SYS.REQ.SERVER.{id}.HEALTHZ requests. /// Handles $SYS.REQ.SERVER.{id}.HEALTHZ requests.
/// Returns a simple health status response. /// Returns a simple health status response.
/// </summary> /// </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) public void HandleHealthzRequest(string subject, string? reply)
{ {
if (reply == null) return; if (reply == null) return;
@@ -2380,6 +2693,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Handles $SYS.REQ.SERVER.{id}.SUBSZ requests. /// Handles $SYS.REQ.SERVER.{id}.SUBSZ requests.
/// Returns the current subscription count. /// Returns the current subscription count.
/// </summary> /// </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) public void HandleSubszRequest(string subject, string? reply)
{ {
if (reply == null) return; if (reply == null) return;
@@ -2390,6 +2705,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Handles $SYS.REQ.SERVER.{id}.STATSZ requests. /// Handles $SYS.REQ.SERVER.{id}.STATSZ requests.
/// Publishes current server statistics through the event system. /// Publishes current server statistics through the event system.
/// </summary> /// </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) public void HandleStatszRequest(string subject, string? reply)
{ {
if (reply == null) return; if (reply == null) return;
@@ -2429,6 +2746,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// Handles $SYS.REQ.SERVER.{id}.IDZ requests. /// Handles $SYS.REQ.SERVER.{id}.IDZ requests.
/// Returns basic server identity information. /// Returns basic server identity information.
/// </summary> /// </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) public void HandleIdzRequest(string subject, string? reply)
{ {
if (reply == null) return; 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 /// Publishes a $SYS.ACCOUNT.{account}.CONNECT advisory when a client
/// completes authentication. Maps to Go's sendConnectEvent in events.go. /// completes authentication. Maps to Go's sendConnectEvent in events.go.
/// </summary> /// </summary>
/// <param name="client">Client that completed CONNECT authentication.</param>
public void PublishConnectEvent(INatsClient client) public void PublishConnectEvent(INatsClient client)
{ {
if (_eventSystem == null || client is not NatsClient natsClient) return; 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 /// Publishes a $SYS.ACCOUNT.{account}.DISCONNECT advisory when a client
/// disconnects. Maps to Go's sendDisconnectEvent in events.go. /// disconnects. Maps to Go's sendDisconnectEvent in events.go.
/// </summary> /// </summary>
/// <param name="client">Client that disconnected from this server.</param>
public void PublishDisconnectEvent(INatsClient client) public void PublishDisconnectEvent(INatsClient client)
{ {
if (_eventSystem == null || client is not NatsClient natsClient) return; if (_eventSystem == null || client is not NatsClient natsClient) return;
@@ -2523,6 +2844,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
SendInternalMsg(subject, null, evt); 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) public void RemoveClient(INatsClient client)
{ {
if (client is not NatsClient natsClient) 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 /// Stores the CLI snapshot and flags so that command-line overrides
/// always take precedence during config reload. /// always take precedence during config reload.
/// </summary> /// </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) public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet<string> cliFlags)
{ {
_cliSnapshot = cliSnapshot; _cliSnapshot = cliSnapshot;
@@ -2718,6 +3045,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
ReloadConfigCore(throwOnError: false); ReloadConfigCore(throwOnError: false);
} }
/// <summary>
/// Reloads configuration and throws when reload validation or apply steps fail.
/// </summary>
public void ReloadConfigOrThrow() public void ReloadConfigOrThrow()
{ {
ReloadConfigCore(throwOnError: true); ReloadConfigCore(throwOnError: true);
@@ -2953,9 +3283,15 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_options.SystemAccount = newOpts.SystemAccount; _options.SystemAccount = newOpts.SystemAccount;
} }
/// <summary>
/// Returns a compact server identity string for diagnostics.
/// </summary>
public override string ToString() public override string ToString()
=> $"NatsServer(ServerId={ServerId}, Name={ServerName}, Addr={Addr()}, Clients={ClientCount})"; => $"NatsServer(ServerId={ServerId}, Name={ServerName}, Addr={Addr()}, Clients={ClientCount})";
/// <summary>
/// Disposes managed resources and signal registrations associated with this server.
/// </summary>
public void Dispose() public void Dispose()
{ {
if (!IsShuttingDown) if (!IsShuttingDown)
+93
View File
@@ -75,58 +75,106 @@ public static class NatsProtocol
public sealed class ServerInfo public sealed class ServerInfo
{ {
/// <summary>
/// Gets or sets the unique server identifier advertised to clients and peers.
/// </summary>
[JsonPropertyName("server_id")] [JsonPropertyName("server_id")]
public required string ServerId { get; set; } public required string ServerId { get; set; }
/// <summary>
/// Gets or sets the human-readable server name.
/// </summary>
[JsonPropertyName("server_name")] [JsonPropertyName("server_name")]
public required string ServerName { get; set; } public required string ServerName { get; set; }
/// <summary>
/// Gets or sets the server version string.
/// </summary>
[JsonPropertyName("version")] [JsonPropertyName("version")]
public required string Version { get; set; } public required string Version { get; set; }
/// <summary>
/// Gets or sets the protocol version number.
/// </summary>
[JsonPropertyName("proto")] [JsonPropertyName("proto")]
public int Proto { get; set; } = NatsProtocol.ProtoVersion; public int Proto { get; set; } = NatsProtocol.ProtoVersion;
/// <summary>
/// Gets or sets the host clients should connect to.
/// </summary>
[JsonPropertyName("host")] [JsonPropertyName("host")]
public required string Host { get; set; } public required string Host { get; set; }
/// <summary>
/// Gets or sets the client port.
/// </summary>
[JsonPropertyName("port")] [JsonPropertyName("port")]
public int Port { get; set; } public int Port { get; set; }
/// <summary>
/// Gets or sets whether header support is enabled.
/// </summary>
[JsonPropertyName("headers")] [JsonPropertyName("headers")]
public bool Headers { get; set; } = true; public bool Headers { get; set; } = true;
/// <summary>
/// Gets or sets the maximum accepted payload size in bytes.
/// </summary>
[JsonPropertyName("max_payload")] [JsonPropertyName("max_payload")]
public int MaxPayload { get; set; } = NatsProtocol.MaxPayloadSize; public int MaxPayload { get; set; } = NatsProtocol.MaxPayloadSize;
/// <summary>
/// Gets or sets the assigned client identifier in per-client INFO payloads.
/// </summary>
[JsonPropertyName("client_id")] [JsonPropertyName("client_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ulong ClientId { get; set; } public ulong ClientId { get; set; }
/// <summary>
/// Gets or sets the remote client IP address in per-client INFO payloads.
/// </summary>
[JsonPropertyName("client_ip")] [JsonPropertyName("client_ip")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientIp { get; set; } public string? ClientIp { get; set; }
/// <summary>
/// Gets or sets whether authentication is required.
/// </summary>
[JsonPropertyName("auth_required")] [JsonPropertyName("auth_required")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool AuthRequired { get; set; } public bool AuthRequired { get; set; }
/// <summary>
/// Gets or sets nonce text used for NKey/JWT auth challenge.
/// </summary>
[JsonPropertyName("nonce")] [JsonPropertyName("nonce")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nonce { get; set; } public string? Nonce { get; set; }
/// <summary>
/// Gets or sets whether TLS is required for clients.
/// </summary>
[JsonPropertyName("tls_required")] [JsonPropertyName("tls_required")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool TlsRequired { get; set; } public bool TlsRequired { get; set; }
/// <summary>
/// Gets or sets whether mutual TLS verification is required.
/// </summary>
[JsonPropertyName("tls_verify")] [JsonPropertyName("tls_verify")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool TlsVerify { get; set; } public bool TlsVerify { get; set; }
/// <summary>
/// Gets or sets whether TLS is available in mixed-mode setups.
/// </summary>
[JsonPropertyName("tls_available")] [JsonPropertyName("tls_available")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool TlsAvailable { get; set; } public bool TlsAvailable { get; set; }
/// <summary>
/// Gets or sets alternative connect URLs advertised to clients.
/// </summary>
[JsonPropertyName("connect_urls")] [JsonPropertyName("connect_urls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? ConnectUrls { get; set; } public string[]? ConnectUrls { get; set; }
@@ -134,48 +182,93 @@ public sealed class ServerInfo
public sealed class ClientOptions public sealed class ClientOptions
{ {
/// <summary>
/// Gets or sets whether `+OK` acknowledgements are requested.
/// </summary>
[JsonPropertyName("verbose")] [JsonPropertyName("verbose")]
public bool Verbose { get; set; } public bool Verbose { get; set; }
/// <summary>
/// Gets or sets whether strict protocol validation is requested.
/// </summary>
[JsonPropertyName("pedantic")] [JsonPropertyName("pedantic")]
public bool Pedantic { get; set; } public bool Pedantic { get; set; }
/// <summary>
/// Gets or sets whether the server should echo publishes back to this client.
/// </summary>
[JsonPropertyName("echo")] [JsonPropertyName("echo")]
public bool Echo { get; set; } = true; public bool Echo { get; set; } = true;
/// <summary>
/// Gets or sets client application name.
/// </summary>
[JsonPropertyName("name")] [JsonPropertyName("name")]
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Gets or sets client implementation language.
/// </summary>
[JsonPropertyName("lang")] [JsonPropertyName("lang")]
public string? Lang { get; set; } public string? Lang { get; set; }
/// <summary>
/// Gets or sets client library version.
/// </summary>
[JsonPropertyName("version")] [JsonPropertyName("version")]
public string? Version { get; set; } public string? Version { get; set; }
/// <summary>
/// Gets or sets protocol mode requested by the client.
/// </summary>
[JsonPropertyName("protocol")] [JsonPropertyName("protocol")]
public int Protocol { get; set; } public int Protocol { get; set; }
/// <summary>
/// Gets or sets whether the client supports headers.
/// </summary>
[JsonPropertyName("headers")] [JsonPropertyName("headers")]
public bool Headers { get; set; } public bool Headers { get; set; }
/// <summary>
/// Gets or sets whether no-responder status messages are requested.
/// </summary>
[JsonPropertyName("no_responders")] [JsonPropertyName("no_responders")]
public bool NoResponders { get; set; } public bool NoResponders { get; set; }
/// <summary>
/// Gets or sets username credential.
/// </summary>
[JsonPropertyName("user")] [JsonPropertyName("user")]
public string? Username { get; set; } public string? Username { get; set; }
/// <summary>
/// Gets or sets password credential.
/// </summary>
[JsonPropertyName("pass")] [JsonPropertyName("pass")]
public string? Password { get; set; } public string? Password { get; set; }
/// <summary>
/// Gets or sets bearer auth token.
/// </summary>
[JsonPropertyName("auth_token")] [JsonPropertyName("auth_token")]
public string? Token { get; set; } public string? Token { get; set; }
/// <summary>
/// Gets or sets NKey public key used for challenge authentication.
/// </summary>
[JsonPropertyName("nkey")] [JsonPropertyName("nkey")]
public string? Nkey { get; set; } public string? Nkey { get; set; }
/// <summary>
/// Gets or sets challenge signature for NKey auth.
/// </summary>
[JsonPropertyName("sig")] [JsonPropertyName("sig")]
public string? Sig { get; set; } public string? Sig { get; set; }
/// <summary>
/// Gets or sets user JWT token.
/// </summary>
[JsonPropertyName("jwt")] [JsonPropertyName("jwt")]
public string? JWT { get; set; } public string? JWT { get; set; }
} }
+95
View File
@@ -36,6 +36,9 @@ public sealed class RouteManager : IAsyncDisposable
private Socket? _listener; private Socket? _listener;
private Task? _acceptLoopTask; private Task? _acceptLoopTask;
/// <summary>
/// Gets the configured route-listen endpoint in `host:port` form.
/// </summary>
public string ListenEndpoint => $"{_options.Host}:{_options.Port}"; public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
/// <summary> /// <summary>
@@ -51,6 +54,7 @@ public sealed class RouteManager : IAsyncDisposable
/// such route connection exists. /// such route connection exists.
/// Go reference: server/route.go negotiateRoutePool. /// Go reference: server/route.go negotiateRoutePool.
/// </summary> /// </summary>
/// <param name="remoteServerId">Optional remote server identifier used to scope lookup.</param>
public int GetEffectivePoolSize(string? remoteServerId) public int GetEffectivePoolSize(string? remoteServerId)
{ {
if (remoteServerId is { Length: > 0 }) if (remoteServerId is { Length: > 0 })
@@ -68,6 +72,9 @@ public sealed class RouteManager : IAsyncDisposable
return ConfiguredPoolSize; return ConfiguredPoolSize;
} }
/// <summary>
/// Builds a snapshot of current route connectivity for monitoring and tests.
/// </summary>
public RouteTopologySnapshot BuildTopologySnapshot() public RouteTopologySnapshot BuildTopologySnapshot()
{ {
return new RouteTopologySnapshot( return new RouteTopologySnapshot(
@@ -76,6 +83,15 @@ public sealed class RouteManager : IAsyncDisposable
_connectedServerIds.Keys.OrderBy(static k => k, StringComparer.Ordinal).ToArray()); _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( public RouteManager(
ClusterOptions options, ClusterOptions options,
ServerStats stats, ServerStats stats,
@@ -106,7 +122,15 @@ public sealed class RouteManager : IAsyncDisposable
/// Go reference: server/route.go forwardNewRouteInfoToKnownServers. /// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
/// </summary> /// </summary>
public event Action<List<string>>? OnForwardInfo; 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; 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; public event Action<string, string>? OnRouteAccountRemoved;
/// <summary> /// <summary>
@@ -114,6 +138,7 @@ public sealed class RouteManager : IAsyncDisposable
/// known are added to DiscoveredRoutes for solicited connection. /// known are added to DiscoveredRoutes for solicited connection.
/// Go reference: server/route.go:1500-1550 (processImplicitRoute). /// Go reference: server/route.go:1500-1550 (processImplicitRoute).
/// </summary> /// </summary>
/// <param name="serverInfo">Peer server INFO payload containing discovered connect URLs.</param>
public void ProcessImplicitRoute(ServerInfo serverInfo) public void ProcessImplicitRoute(ServerInfo serverInfo)
{ {
if (serverInfo.ConnectUrls is null || serverInfo.ConnectUrls.Length == 0) 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. /// Forwards new peer URL information to all known route connections.
/// Go reference: server/route.go forwardNewRouteInfoToKnownServers. /// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
/// </summary> /// </summary>
/// <param name="newPeerUrl">New peer route URL to gossip to connected peers.</param>
public void ForwardNewRouteInfoToKnownServers(string newPeerUrl) public void ForwardNewRouteInfoToKnownServers(string newPeerUrl)
{ {
OnForwardInfo?.Invoke([newPeerUrl]); OnForwardInfo?.Invoke([newPeerUrl]);
@@ -150,6 +176,7 @@ public sealed class RouteManager : IAsyncDisposable
/// <summary> /// <summary>
/// Adds a URL to the known route set. Used during initialization and testing. /// Adds a URL to the known route set. Used during initialization and testing.
/// </summary> /// </summary>
/// <param name="url">Route URL to register as known/configured.</param>
public void AddKnownRoute(string url) public void AddKnownRoute(string url)
{ {
lock (_discoveredRoutes) lock (_discoveredRoutes)
@@ -163,6 +190,7 @@ public sealed class RouteManager : IAsyncDisposable
/// known from startup/config processing. /// known from startup/config processing.
/// Go reference: server/route.go hasThisRouteConfigured. /// Go reference: server/route.go hasThisRouteConfigured.
/// </summary> /// </summary>
/// <param name="routeUrl">Route URL to evaluate.</param>
internal bool HasThisRouteConfigured(string routeUrl) internal bool HasThisRouteConfigured(string routeUrl)
{ {
var normalized = NormalizeRouteUrl(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. /// Returns true if the route URL is still valid for reconnect attempts.
/// Go reference: server/route.go routeStillValid. /// Go reference: server/route.go routeStillValid.
/// </summary> /// </summary>
/// <param name="routeUrl">Route URL to validate against configured/discovered sets.</param>
internal bool RouteStillValid(string routeUrl) internal bool RouteStillValid(string routeUrl)
{ {
var normalized = NormalizeRouteUrl(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 /// <c>computeRoutePoolIdx</c> (route.go:533-545). Uses FNV-1a 32-bit hash
/// to deterministically map account names to pool indices. /// to deterministically map account names to pool indices.
/// </summary> /// </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) public static int ComputeRoutePoolIdx(int poolSize, string accountName)
{ {
if (poolSize <= 1) if (poolSize <= 1)
@@ -222,6 +253,7 @@ public sealed class RouteManager : IAsyncDisposable
/// Go reference: server/route.go — per-account dedicated route lookup, /// Go reference: server/route.go — per-account dedicated route lookup,
/// getRoutesExcludePool (legacy fallback). /// getRoutesExcludePool (legacy fallback).
/// </summary> /// </summary>
/// <param name="account">Account name requiring a route connection.</param>
public RouteConnection? GetRouteForAccount(string account) public RouteConnection? GetRouteForAccount(string account)
{ {
// 1st: Check dedicated account routes (Gap 13.2). // 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. /// previous connection was registered for the same account it is replaced.
/// Go reference: server/route.go — per-account dedicated route registration. /// Go reference: server/route.go — per-account dedicated route registration.
/// </summary> /// </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) public void RegisterAccountRoute(string account, RouteConnection connection)
{ {
_accountRoutes[account] = 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 /// Removes the dedicated route for the given account. If no dedicated route
/// was registered this is a no-op. /// was registered this is a no-op.
/// </summary> /// </summary>
/// <param name="account">Account name whose dedicated route should be removed.</param>
public void UnregisterAccountRoute(string account) public void UnregisterAccountRoute(string account)
{ {
if (!_accountRoutes.TryRemove(account, out var route)) 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 /// Returns the dedicated route connection for the given account, or null if
/// no dedicated route has been registered. /// no dedicated route has been registered.
/// </summary> /// </summary>
/// <param name="account">Account name to look up.</param>
public RouteConnection? GetDedicatedAccountRoute(string account) public RouteConnection? GetDedicatedAccountRoute(string account)
=> _accountRoutes.TryGetValue(account, out var connection) ? connection : null; => _accountRoutes.TryGetValue(account, out var connection) ? connection : null;
/// <summary> /// <summary>
/// Returns true when a dedicated route is registered for the given account. /// Returns true when a dedicated route is registered for the given account.
/// </summary> /// </summary>
/// <param name="account">Account name to check.</param>
public bool HasDedicatedRoute(string account) public bool HasDedicatedRoute(string account)
=> _accountRoutes.ContainsKey(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. /// index variant but uses 64-bit constants for a wider key space.
/// Go reference: server/route.go — route hash key derivation. /// Go reference: server/route.go — route hash key derivation.
/// </summary> /// </summary>
/// <param name="serverId">Remote server identifier to hash.</param>
public static ulong ComputeRouteHash(string serverId) public static ulong ComputeRouteHash(string serverId)
{ {
const ulong fnvOffset = 14695981039346656037UL; const ulong fnvOffset = 14695981039346656037UL;
@@ -347,6 +385,8 @@ public sealed class RouteManager : IAsyncDisposable
/// same server ID is overwritten. /// same server ID is overwritten.
/// Go reference: server/route.go — route hash registration. /// Go reference: server/route.go — route hash registration.
/// </summary> /// </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) public void RegisterRouteByHash(string serverId, RouteConnection connection)
{ {
var hash = ComputeRouteHash(serverId); var hash = ComputeRouteHash(serverId);
@@ -358,6 +398,7 @@ public sealed class RouteManager : IAsyncDisposable
/// If no entry exists this is a no-op. /// If no entry exists this is a no-op.
/// Go reference: server/route.go — route hash deregistration. /// Go reference: server/route.go — route hash deregistration.
/// </summary> /// </summary>
/// <param name="serverId">Remote server identifier whose hash entry should be removed.</param>
public void UnregisterRouteByHash(string serverId) public void UnregisterRouteByHash(string serverId)
{ {
var hash = ComputeRouteHash(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. /// or <c>null</c> if no entry exists. O(1) lookup.
/// Go reference: server/route.go — O(1) route lookup by hash. /// Go reference: server/route.go — O(1) route lookup by hash.
/// </summary> /// </summary>
/// <param name="hash">Precomputed route hash key.</param>
public RouteConnection? GetRouteByHash(ulong hash) public RouteConnection? GetRouteByHash(ulong hash)
=> _routesByHash.TryGetValue(hash, out var connection) ? connection : null; => _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>. /// and returns the associated route connection, or <c>null</c>.
/// Go reference: server/route.go — server-ID-keyed route lookup. /// Go reference: server/route.go — server-ID-keyed route lookup.
/// </summary> /// </summary>
/// <param name="serverId">Remote server identifier to resolve.</param>
public RouteConnection? GetRouteByServerId(string serverId) public RouteConnection? GetRouteByServerId(string serverId)
=> GetRouteByHash(ComputeRouteHash(serverId)); => GetRouteByHash(ComputeRouteHash(serverId));
@@ -385,6 +428,10 @@ public sealed class RouteManager : IAsyncDisposable
/// </summary> /// </summary>
public int HashedRouteCount => _routesByHash.Count; 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) public Task StartAsync(CancellationToken ct)
{ {
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -412,6 +459,9 @@ public sealed class RouteManager : IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>
/// Stops route listener/dials and disposes active route connections.
/// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_cts == null) if (_cts == null)
@@ -434,6 +484,12 @@ public sealed class RouteManager : IAsyncDisposable
_cts = null; _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) public void PropagateLocalSubscription(string account, string subject, string? queue)
{ {
if (_routes.IsEmpty) 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) public void PropagateLocalUnsubscription(string account, string subject, string? queue)
{ {
if (_routes.IsEmpty) 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) public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
{ {
if (_routes.IsEmpty) if (_routes.IsEmpty)
@@ -497,6 +567,11 @@ public sealed class RouteManager : IAsyncDisposable
/// Used for JetStream replication where every peer must receive the message. /// Used for JetStream replication where every peer must receive the message.
/// Go reference: server/route.go — broadcastMsgToRoutes for RAFT proposals. /// Go reference: server/route.go — broadcastMsgToRoutes for RAFT proposals.
/// </summary> /// </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) public async Task BroadcastRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
{ {
if (_routes.IsEmpty) 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() internal static Socket CreateRouteDialSocket()
{ {
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 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])); 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; public int RouteCount => _routes.Count;
/// <summary> /// <summary>
@@ -700,6 +781,8 @@ public sealed class RouteManager : IAsyncDisposable
/// and for programmatic registration of routes by server ID. /// and for programmatic registration of routes by server ID.
/// Go reference: server/route.go addRoute (internal registration path). /// Go reference: server/route.go addRoute (internal registration path).
/// </summary> /// </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) internal void RegisterRoute(string serverId, RouteConnection connection)
{ {
var key = $"{serverId}:{Guid.NewGuid():N}"; 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. /// Returns true if a route was found and removed, false otherwise.
/// Go reference: server/route.go removeRoute (lines 3113+). /// Go reference: server/route.go removeRoute (lines 3113+).
/// </summary> /// </summary>
/// <param name="serverId">Remote server identifier to remove.</param>
public bool RemoveRoute(string serverId) public bool RemoveRoute(string serverId)
{ {
var found = false; var found = false;
@@ -758,6 +842,7 @@ public sealed class RouteManager : IAsyncDisposable
/// Returns the number of routes removed. /// Returns the number of routes removed.
/// Go reference: server/route.go removeAllRoutesExcept (lines 3085-3111). /// Go reference: server/route.go removeAllRoutesExcept (lines 3085-3111).
/// </summary> /// </summary>
/// <param name="keepServerIds">Server IDs that should remain connected.</param>
public int RemoveAllRoutesExcept(IReadOnlySet<string> keepServerIds) public int RemoveAllRoutesExcept(IReadOnlySet<string> keepServerIds)
{ {
var removed = 0; var removed = 0;
@@ -787,6 +872,7 @@ public sealed class RouteManager : IAsyncDisposable
/// are absent from the connected set. /// are absent from the connected set.
/// Go reference: server/route.go — cluster split / network partition detection. /// Go reference: server/route.go — cluster split / network partition detection.
/// </summary> /// </summary>
/// <param name="expectedPeers">Expected full peer set for a healthy cluster.</param>
public ClusterSplitResult DetectClusterSplit(IReadOnlySet<string> expectedPeers) public ClusterSplitResult DetectClusterSplit(IReadOnlySet<string> expectedPeers)
{ {
var connected = _connectedServerIds.Keys.ToHashSet(StringComparer.Ordinal); var connected = _connectedServerIds.Keys.ToHashSet(StringComparer.Ordinal);
@@ -804,6 +890,9 @@ public sealed class RouteManager : IAsyncDisposable
return new ClusterSplitResult(missing, unexpected, missing.Count > 0); 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) internal bool HasSolicitedRoute(string remoteServerId)
{ {
var prefix = remoteServerId + ":"; var prefix = remoteServerId + ":";
@@ -813,6 +902,9 @@ public sealed class RouteManager : IAsyncDisposable
|| string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal))); || 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) internal bool UpgradeRouteToSolicited(string remoteServerId)
{ {
var prefix = remoteServerId + ":"; var prefix = remoteServerId + ":";
@@ -831,6 +923,9 @@ public sealed class RouteManager : IAsyncDisposable
return false; return false;
} }
/// <summary>
/// Returns whether the remote server ID is already present in the connected set.
/// </summary>
internal bool IsDuplicateServerName(string remoteServerId) internal bool IsDuplicateServerName(string remoteServerId)
=> _connectedServerIds.ContainsKey(remoteServerId); => _connectedServerIds.ContainsKey(remoteServerId);
+153
View File
@@ -35,27 +35,56 @@ public sealed class SubList : IDisposable
private readonly Dictionary<string, List<Action<bool>>> _queueRemoveNotifications = new(StringComparer.Ordinal); private readonly Dictionary<string, List<Action<bool>>> _queueRemoveNotifications = new(StringComparer.Ordinal);
private readonly record struct CachedResult(SubListResult Result, long Generation); 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; public event Action<InterestChange>? InterestChanged;
/// <summary>
/// Creates a subscription list with match-result caching enabled.
/// </summary>
public SubList() public SubList()
: this(enableCache: true) : 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) public SubList(bool enableCache)
{ {
if (!enableCache) if (!enableCache)
_cache = null; _cache = null;
} }
/// <summary>
/// Creates a subscription list with caching disabled.
/// </summary>
public static SubList NewSublistNoCache() => new(enableCache: false); public static SubList NewSublistNoCache() => new(enableCache: false);
/// <summary>
/// Returns whether match-result caching is currently enabled.
/// </summary>
public bool CacheEnabled() => _cache != null; 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; public void RegisterNotification(Action<bool> callback) => _interestStateNotification = callback;
/// <summary>
/// Clears the overall interest transition callback.
/// </summary>
public void ClearNotification() => _interestStateNotification = null; 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) public bool RegisterQueueNotification(string subject, string queue, Action<bool> callback)
{ {
if (callback == null || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(queue)) if (callback == null || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(queue))
@@ -82,6 +111,12 @@ public sealed class SubList : IDisposable
return true; 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) public bool ClearQueueNotification(string subject, string queue, Action<bool> callback)
{ {
var key = QueueNotifyKey(subject, queue); 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() public void Dispose()
{ {
_disposed = true; _disposed = true;
_lock.Dispose(); _lock.Dispose();
} }
/// <summary>
/// Gets the number of subscriptions currently stored in the trie.
/// </summary>
public uint Count public uint Count
{ {
get 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); internal int HighFanoutNodeCountForTest => Volatile.Read(ref _highFanoutNodes);
/// <summary>
/// Triggers cache sweeping immediately for deterministic tests.
/// </summary>
internal Task TriggerCacheSweepAsyncForTest() => _sweeper.TriggerSweepAsync(SweepCache); 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) public void ApplyRemoteSub(RemoteSubscription sub)
{ {
_lock.EnterWriteLock(); _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) public void UpdateRemoteQSub(RemoteSubscription sub)
{ {
if (sub.Queue == null) 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) public int RemoveRemoteSubs(string routeId)
{ {
_lock.EnterWriteLock(); _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) public int RemoveRemoteSubsForAccount(string routeId, string account)
{ {
_lock.EnterWriteLock(); _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) public bool HasRemoteInterest(string subject)
=> HasRemoteInterest("$G", 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) public bool HasRemoteInterest(string account, string subject)
{ {
_lock.EnterReadLock(); _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) public void Insert(Subscription sub)
{ {
var subject = sub.Subject; 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) public void Remove(Subscription sub)
{ {
if (_disposed) return; if (_disposed) return;
@@ -552,6 +633,10 @@ public sealed class SubList : IDisposable
return true; 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) public SubListResult Match(string subject)
{ {
_matches++; _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) public SubListResult MatchBytes(ReadOnlySpan<byte> subjectUtf8)
{ {
return Match(Encoding.ASCII.GetString(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) public IReadOnlyList<RemoteSubscription> MatchRemote(string account, string subject)
{ {
_lock.EnterReadLock(); _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() public SubListStats Stats()
{ {
_lock.EnterReadLock(); _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) public bool HasInterest(string subject)
{ {
var currentGen = Interlocked.Read(ref _generation); 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) public (int plainCount, int queueCount) NumInterest(string subject)
{ {
var tokens = Tokenize(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) public void RemoveBatch(IEnumerable<Subscription> subs)
{ {
_lock.EnterWriteLock(); _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() public IReadOnlyList<Subscription> All()
{ {
var subs = new List<Subscription>(); var subs = new List<Subscription>();
@@ -1077,6 +1189,10 @@ public sealed class SubList : IDisposable
return subs; 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) public IReadOnlyList<Subscription> LocalSubs(bool includeLeafHubs = false)
{ {
var subs = new List<Subscription>(); var subs = new List<Subscription>();
@@ -1092,6 +1208,9 @@ public sealed class SubList : IDisposable
return subs; return subs;
} }
/// <summary>
/// Returns trie depth for diagnostic/test visibility.
/// </summary>
internal int NumLevels() internal int NumLevels()
{ {
_lock.EnterReadLock(); _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) public SubListResult ReverseMatch(string subject)
{ {
var tokens = Tokenize(subject); var tokens = Tokenize(subject);
@@ -1391,16 +1514,29 @@ public sealed class SubList : IDisposable
{ {
private ReadOnlySpan<char> _remaining; 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) public TokenEnumerator(string subject)
{ {
_remaining = subject.AsSpan(); _remaining = subject.AsSpan();
Current = default; Current = default;
} }
/// <summary>
/// Gets the current token slice.
/// </summary>
public ReadOnlySpan<char> Current { get; private set; } public ReadOnlySpan<char> Current { get; private set; }
/// <summary>
/// Returns the enumerator instance for `foreach` support.
/// </summary>
public TokenEnumerator GetEnumerator() => this; public TokenEnumerator GetEnumerator() => this;
/// <summary>
/// Advances to the next token.
/// </summary>
public bool MoveNext() public bool MoveNext()
{ {
if (_remaining.IsEmpty) if (_remaining.IsEmpty)
@@ -1435,6 +1571,9 @@ public sealed class SubList : IDisposable
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal); public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
public bool PackedListEnabled; 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 && public bool IsEmpty => PlainSubs.Count == 0 && QueueSubs.Count == 0 &&
(Next == null || (Next.Nodes.Count == 0 && Next.Pwc == null && Next.Fwc == null)); (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 readonly List<List<Subscription>> _queueGroups = [];
private int _queueGroupCount; private int _queueGroupCount;
/// <summary>
/// Gets the accumulated plain subscriptions for the current match operation.
/// </summary>
public List<Subscription> PlainSubs { get; } = []; public List<Subscription> PlainSubs { get; } = [];
/// <summary>
/// Clears all accumulated match state for reuse.
/// </summary>
public void Reset() public void Reset()
{ {
PlainSubs.Clear(); PlainSubs.Clear();
@@ -1456,6 +1601,11 @@ public sealed class SubList : IDisposable
_queueGroupCount = 0; _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) public void AddQueueGroup(string queueName, HashSet<Subscription> subs)
{ {
if (!_queueIndexes.TryGetValue(queueName, out var index)) if (!_queueIndexes.TryGetValue(queueName, out var index))
@@ -1469,6 +1619,9 @@ public sealed class SubList : IDisposable
_queueGroups[index].AddRange(subs); _queueGroups[index].AddRange(subs);
} }
/// <summary>
/// Materializes the builder state into an immutable match result.
/// </summary>
public SubListResult ToResult() public SubListResult ToResult()
{ {
if (PlainSubs.Count == 0 && _queueGroupCount == 0) if (PlainSubs.Count == 0 && _queueGroupCount == 0)