Compare commits
40 Commits
b2707a7493
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de4962bd3 | |||
| 46ead5ea9f | |||
| ba0d65317a | |||
| 56c773dc71 | |||
| 007baf3fa4 | |||
| 88a82ee860 | |||
| 1d4b87e5f9 | |||
| 660a897234 | |||
| 0e5ce4ed9b | |||
| 23543b2ba8 | |||
| 82ab02a612 | |||
| 6e91fda7fd | |||
| e4ab48bca4 | |||
| a62a25dcdf | |||
| 7404ecdb0e | |||
| 82cc3ec841 | |||
| 86fd971510 | |||
| f7a8d72a6d | |||
| 7b2def4da1 | |||
| 11e01b9026 | |||
| 699449da6a | |||
| 497aa227af | |||
| 4b15f643f6 | |||
| a470e0bcdb | |||
| 5a00708a79 | |||
| a5592ed533 | |||
| 20f45b2aaf | |||
| ca2d8019a1 | |||
| f57edca5a8 | |||
| 9ff5216495 | |||
| 5674853628 | |||
| 655ca30e0b | |||
| a1fc600d84 | |||
| fb0d31c615 | |||
| 900a4b0923 | |||
| d1f22255d7 | |||
| 0126234fa6 | |||
| 5876ad7dfa | |||
| 348bec36b2 | |||
| 08bd34c529 |
@@ -286,43 +286,31 @@ public ValueTask<ulong> AppendAsync(string subject, ReadOnlyMemory<byte> payload
|
||||
|
||||
### FileStore
|
||||
|
||||
`FileStore` appends messages to a JSONL file (`messages.jsonl`) and keeps a full in-memory index (`Dictionary<ulong, StoredMessage>`) identical in structure to `MemStore`. It is not production-safe for several reasons:
|
||||
|
||||
- **No locking**: `AppendAsync`, `LoadAsync`, `GetStateAsync`, and `TrimToMaxMessages` are not synchronized. Concurrent access from `StreamManager.Capture` and `PullConsumerEngine.FetchAsync` is unsafe.
|
||||
- **Per-write file I/O**: Each `AppendAsync` calls `File.AppendAllTextAsync`, issuing a separate file open/write/close per message.
|
||||
- **Full rewrite on trim**: `TrimToMaxMessages` calls `RewriteDataFile()`, which rewrites the entire file from the in-memory index. This is O(n) in message count and blocking.
|
||||
- **Full in-memory index**: The in-memory dictionary holds every undeleted message payload; there is no paging or streaming read path.
|
||||
`FileStore` now persists messages into block files via `MsgBlock`, keeps a live in-memory message cache for load paths, and maintains a compact metadata index (`Dictionary<ulong, StoredMessageIndex>`) plus a per-subject last-sequence map for hot-path lookups such as `LoadLastBySubjectAsync`. Headers and payloads are stored separately and remain separate across snapshot, restore, block rewrite, and crash recovery. On startup, any legacy `messages.jsonl` file is migrated into block storage before recovery continues.
|
||||
|
||||
```csharp
|
||||
// FileStore.cs
|
||||
public void TrimToMaxMessages(ulong maxMessages)
|
||||
private readonly Dictionary<ulong, StoredMessage> _messages = new();
|
||||
private readonly Dictionary<ulong, StoredMessageIndex> _messageIndexes = new();
|
||||
private readonly Dictionary<string, ulong> _lastSequenceBySubject = new(StringComparer.Ordinal);
|
||||
|
||||
public ValueTask<StoredMessage?> LoadLastBySubjectAsync(string subject, CancellationToken ct)
|
||||
{
|
||||
while ((ulong)_messages.Count > maxMessages)
|
||||
if (_lastSequenceBySubject.TryGetValue(subject, out var sequence)
|
||||
&& _messages.TryGetValue(sequence, out var match))
|
||||
{
|
||||
var first = _messages.Keys.Min();
|
||||
_messages.Remove(first);
|
||||
return ValueTask.FromResult<StoredMessage?>(match);
|
||||
}
|
||||
|
||||
RewriteDataFile();
|
||||
}
|
||||
|
||||
private void RewriteDataFile()
|
||||
{
|
||||
var lines = new List<string>(_messages.Count);
|
||||
foreach (var message in _messages.OrderBy(kv => kv.Key).Select(kv => kv.Value))
|
||||
{
|
||||
lines.Add(JsonSerializer.Serialize(new FileRecord
|
||||
{
|
||||
Sequence = message.Sequence,
|
||||
Subject = message.Subject,
|
||||
PayloadBase64 = Convert.ToBase64String(message.Payload.ToArray()),
|
||||
}));
|
||||
}
|
||||
File.WriteAllLines(_dataFilePath, lines);
|
||||
return ValueTask.FromResult<StoredMessage?>(null);
|
||||
}
|
||||
```
|
||||
|
||||
The Go reference (`filestore.go`) uses block-based binary storage with S2 compression, per-block indexes, and memory-mapped I/O. This implementation shares none of those properties.
|
||||
The current implementation is still materially simpler than Go `filestore.go`:
|
||||
|
||||
- **No synchronization**: `FileStore` still exposes unsynchronized mutation and read paths. It is safe only under the current test and single-process usage assumptions.
|
||||
- **Payloads still stay resident**: the compact index removes duplicate payload ownership for metadata-heavy operations, but `_messages` still retains live payload bytes in memory for direct load paths.
|
||||
- **No Go-equivalent block index stack**: there is no per-block subject tree, mmap-backed read path, or Go-style cache/compaction parity. Deletes and trims rely on tombstones plus later block maintenance rather than Go's full production filestore behavior.
|
||||
|
||||
---
|
||||
|
||||
@@ -445,7 +433,7 @@ The following features are present in the Go reference (`golang/nats-server/serv
|
||||
- **Ephemeral consumers**: `ConsumerManager.CreateOrUpdate` requires a non-empty `DurableName`. There is no support for unnamed ephemeral consumers.
|
||||
- **Push delivery over the NATS wire**: Push consumers enqueue `PushFrame` objects into an in-memory queue. No MSG is written to any connected NATS client's TCP socket.
|
||||
- **Consumer filter subject enforcement**: `FilterSubject` is stored on `ConsumerConfig` but is never applied in `PullConsumerEngine.FetchAsync`. All messages in the stream are returned regardless of filter.
|
||||
- **FileStore production safety**: No locking, per-write file I/O, full-rewrite-on-trim, and full in-memory index make `FileStore` unsuitable for production use.
|
||||
- **FileStore production safety**: `FileStore` now uses block files and compact metadata indexes, but it still lacks synchronization and Go-level block indexing, so it remains unsuitable for production use.
|
||||
- **RAFT persistence and networking**: `RaftNode` log entries are not persisted across restarts. Replication uses direct in-process method calls; there is no network transport for multi-server consensus.
|
||||
- **Cross-server replication**: Mirror and source coordinators work only within one `StreamManager` in one process. Messages published on a remote server are not replicated.
|
||||
- **Duplicate message window**: `PublishPreconditions` tracks message IDs for deduplication but there is no configurable `DuplicateWindow` TTL to expire old IDs.
|
||||
@@ -460,4 +448,4 @@ The following features are present in the Go reference (`golang/nats-server/serv
|
||||
- [Configuration Overview](../Configuration/Overview.md)
|
||||
- [Protocol Overview](../Protocol/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-23 -->
|
||||
<!-- Last verified against codebase: 2026-03-13 -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SubList
|
||||
|
||||
`SubList` is the subscription routing trie. Every published message triggers a `Match()` call to find all interested subscribers. `SubList` stores subscriptions indexed by their subject tokens and returns a `SubListResult` containing both plain subscribers and queue groups.
|
||||
`SubList` is the subscription routing trie for the core server. Every publish path calls `Match()` to find the local plain subscribers and queue groups interested in a subject. The type also tracks remote route and gateway interest so clustering code can answer `HasRemoteInterest(...)` and `MatchRemote(...)` queries without a second routing structure.
|
||||
|
||||
Go reference: `golang/nats-server/server/sublist.go`
|
||||
|
||||
@@ -8,32 +8,30 @@ Go reference: `golang/nats-server/server/sublist.go`
|
||||
|
||||
## Thread Safety
|
||||
|
||||
`SubList` uses a `ReaderWriterLockSlim` (`_lock`) with the following locking discipline:
|
||||
`SubList` uses a single `ReaderWriterLockSlim` (`_lock`) to protect trie mutation, remote-interest bookkeeping, and cache state.
|
||||
|
||||
| Operation | Lock |
|
||||
|-----------|------|
|
||||
| `Count` read | Read lock |
|
||||
| `Match()` — cache hit | Read lock only |
|
||||
| `Match()` — cache miss | Write lock (to update cache) |
|
||||
| `Insert()` | Write lock |
|
||||
| `Remove()` | Write lock |
|
||||
| Cache hit in `Match()` | Read lock |
|
||||
| Cache miss in `Match()` | Write lock |
|
||||
| `Insert()` / `Remove()` / `RemoveBatch()` | Write lock |
|
||||
| Remote-interest mutation (`ApplyRemoteSub`, `UpdateRemoteQSub`, cleanup) | Write lock |
|
||||
| Read-only queries (`Count`, `HasRemoteInterest`, `MatchRemote`, `Stats`) | Read lock |
|
||||
|
||||
Cache misses in `Match()` require a write lock because the cache must be updated after the trie traversal. To avoid a race between the read-lock check and the write-lock update, `Match()` uses double-checked locking: after acquiring the write lock, it checks the cache again before doing trie work.
|
||||
`Match()` uses generation-based double-checked locking. It first checks the cache under a read lock, then retries under the write lock before traversing the trie and updating the cache.
|
||||
|
||||
---
|
||||
|
||||
## Trie Structure
|
||||
|
||||
The trie is built from two private classes, `TrieLevel` and `TrieNode`, nested inside `SubList`.
|
||||
|
||||
### `TrieLevel` and `TrieNode`
|
||||
The trie is built from `TrieLevel` and `TrieNode`:
|
||||
|
||||
```csharp
|
||||
private sealed class TrieLevel
|
||||
{
|
||||
public readonly Dictionary<string, TrieNode> Nodes = new(StringComparer.Ordinal);
|
||||
public TrieNode? Pwc; // partial wildcard (*)
|
||||
public TrieNode? Fwc; // full wildcard (>)
|
||||
public TrieNode? Pwc;
|
||||
public TrieNode? Fwc;
|
||||
}
|
||||
|
||||
private sealed class TrieNode
|
||||
@@ -41,202 +39,149 @@ private sealed class TrieNode
|
||||
public TrieLevel? Next;
|
||||
public readonly HashSet<Subscription> PlainSubs = [];
|
||||
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsEmpty => PlainSubs.Count == 0 && QueueSubs.Count == 0 &&
|
||||
(Next == null || (Next.Nodes.Count == 0 && Next.Pwc == null && Next.Fwc == null));
|
||||
public bool PackedListEnabled;
|
||||
}
|
||||
```
|
||||
|
||||
Each level in the trie represents one token position in a subject. A `TrieLevel` holds:
|
||||
- `Nodes` stores literal-token edges by exact token string.
|
||||
- `Pwc` stores the `*` edge for the current token position.
|
||||
- `Fwc` stores the `>` edge for the current token position.
|
||||
- `PlainSubs` stores non-queue subscriptions attached to the terminal node.
|
||||
- `QueueSubs` groups queue subscriptions by queue name at the terminal node.
|
||||
|
||||
- `Nodes` — a dictionary keyed by literal token string, mapping to the `TrieNode` for that token. Uses `StringComparer.Ordinal` for performance.
|
||||
- `Pwc` — the node for the `*` wildcard at this level, or `null` if no `*` subscriptions exist at this depth.
|
||||
- `Fwc` — the node for the `>` wildcard at this level, or `null` if no `>` subscriptions exist at this depth.
|
||||
The root of the trie is `_root`, a `TrieLevel` with no parent node.
|
||||
|
||||
A `TrieNode` sits at the boundary between two levels. It holds the subscriptions registered for subjects whose last token leads to this node:
|
||||
---
|
||||
|
||||
- `PlainSubs` — a `HashSet<Subscription>` of plain (non-queue) subscribers.
|
||||
- `QueueSubs` — a dictionary from queue name to the set of members in that queue group. Uses `StringComparer.Ordinal`.
|
||||
- `Next` — the next `TrieLevel` for deeper token positions. `null` for leaf nodes.
|
||||
- `IsEmpty` — `true` when the node and all its descendants have no subscriptions. Used during `Remove()` to prune dead branches.
|
||||
## Token Traversal
|
||||
|
||||
The trie root is a `TrieLevel` (`_root`) with no parent node.
|
||||
`TokenEnumerator` walks a subject string token-by-token using `ReadOnlySpan<char>` slices, so traversal itself does not allocate. Literal-token insert and remove paths use `TryGetLiteralNode(...)` plus `SubjectMatch.TokenEquals(...)` to reuse the existing trie key string when the token is already present, instead of calling `token.ToString()` on every hop.
|
||||
|
||||
### `TokenEnumerator`
|
||||
That keeps literal-subject maintenance allocation-lean while preserving the current `Dictionary<string, TrieNode>` storage model.
|
||||
|
||||
`TokenEnumerator` is a `ref struct` that splits a subject string by `.` without allocating. It operates on a `ReadOnlySpan<char>` derived from the original string.
|
||||
---
|
||||
|
||||
## Local Subscription Operations
|
||||
|
||||
### Insert
|
||||
|
||||
`Insert(Subscription sub)` walks the trie one token at a time:
|
||||
|
||||
- `*` follows or creates `Pwc`
|
||||
- `>` follows or creates `Fwc` and terminates further token traversal
|
||||
- literal tokens follow or create `Nodes[token]`
|
||||
|
||||
The terminal node stores the subscription in either `PlainSubs` or the appropriate queue-group bucket in `QueueSubs`. Every successful insert increments `_generation`, which invalidates cached match results.
|
||||
|
||||
### Remove
|
||||
|
||||
`Remove(Subscription sub)` and `RemoveBatch(IEnumerable<Subscription>)` walk the same subject path, remove the subscription from the terminal node, and then prune empty trie nodes on the way back out. Removing a subscription also bumps `_generation`, so any stale cached result is ignored on the next lookup.
|
||||
|
||||
---
|
||||
|
||||
## Remote Interest Bookkeeping
|
||||
|
||||
Remote route and gateway subscriptions are stored separately from the local trie in `_remoteSubs`:
|
||||
|
||||
```csharp
|
||||
private ref struct TokenEnumerator
|
||||
{
|
||||
private ReadOnlySpan<char> _remaining;
|
||||
|
||||
public TokenEnumerator(string subject)
|
||||
{
|
||||
_remaining = subject.AsSpan();
|
||||
Current = default;
|
||||
}
|
||||
|
||||
public ReadOnlySpan<char> Current { get; private set; }
|
||||
|
||||
public TokenEnumerator GetEnumerator() => this;
|
||||
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_remaining.IsEmpty)
|
||||
return false;
|
||||
|
||||
int sep = _remaining.IndexOf(SubjectMatch.Sep);
|
||||
if (sep < 0)
|
||||
{
|
||||
Current = _remaining;
|
||||
_remaining = default;
|
||||
}
|
||||
else
|
||||
{
|
||||
Current = _remaining[..sep];
|
||||
_remaining = _remaining[(sep + 1)..];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
private readonly Dictionary<RoutedSubKey, RemoteSubscription> _remoteSubs = [];
|
||||
```
|
||||
|
||||
`TokenEnumerator` implements the `foreach` pattern directly (via `GetEnumerator()` returning `this`), so it can be used in `foreach` loops without boxing. `Insert()` uses it during trie traversal to avoid string allocations per token.
|
||||
|
||||
---
|
||||
|
||||
## Insert
|
||||
|
||||
`Insert(Subscription sub)` adds a subscription to the trie under a write lock.
|
||||
|
||||
The method walks the trie one token at a time using `TokenEnumerator`. For each token:
|
||||
- If the token is `*`, it creates or follows `level.Pwc`.
|
||||
- If the token is `>`, it creates or follows `level.Fwc` and sets `sawFwc = true` to reject further tokens.
|
||||
- Otherwise it creates or follows `level.Nodes[token]`.
|
||||
|
||||
At each step, `node.Next` is created if absent, and `level` advances to `node.Next`.
|
||||
|
||||
After all tokens are consumed, the subscription is added to the terminal node:
|
||||
- Plain subscription: `node.PlainSubs.Add(sub)`.
|
||||
- Queue subscription: `node.QueueSubs[sub.Queue].Add(sub)`, creating the inner `HashSet<Subscription>` if this is the first member of that group.
|
||||
|
||||
`_count` is incremented and `AddToCache` is called to update any cached results that would now include this subscription.
|
||||
|
||||
---
|
||||
|
||||
## Remove
|
||||
|
||||
`Remove(Subscription sub)` removes a subscription from the trie under a write lock.
|
||||
|
||||
The method walks the trie along the subscription's subject, recording the path as a `List<(TrieLevel, TrieNode, string token, bool isPwc, bool isFwc)>`. If any node along the path is missing, the method returns without error (the subscription was never inserted).
|
||||
|
||||
After locating the terminal node, the subscription is removed from `PlainSubs` or from the appropriate `QueueSubs` group. If the queue group becomes empty, its entry is removed from the dictionary.
|
||||
|
||||
If removal succeeds:
|
||||
- `_count` is decremented.
|
||||
- `RemoveFromCache` is called to invalidate affected cache entries.
|
||||
- The path list is walked backwards. At each step, if `node.IsEmpty` is `true`, the node is removed from its parent level (`Pwc = null`, `Fwc = null`, or `Nodes.Remove(token)`). This prunes dead branches so the trie does not accumulate empty nodes over time.
|
||||
|
||||
---
|
||||
|
||||
## Match
|
||||
|
||||
`Match(string subject)` is called for every published message. It returns a `SubListResult` containing all matching plain and queue subscriptions.
|
||||
|
||||
### Cache check and fallback
|
||||
`RoutedSubKey` is a compact value key:
|
||||
|
||||
```csharp
|
||||
public SubListResult Match(string subject)
|
||||
{
|
||||
// Check cache under read lock first.
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached))
|
||||
return cached;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
|
||||
// Cache miss -- tokenize and match under write lock (needed for cache update).
|
||||
var tokens = Tokenize(subject);
|
||||
if (tokens == null)
|
||||
return SubListResult.Empty;
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
// Re-check cache after acquiring write lock.
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached))
|
||||
return cached;
|
||||
|
||||
var plainSubs = new List<Subscription>();
|
||||
var queueSubs = new List<List<Subscription>>();
|
||||
|
||||
MatchLevel(_root, tokens, 0, plainSubs, queueSubs);
|
||||
...
|
||||
if (_cache != null)
|
||||
{
|
||||
_cache[subject] = result;
|
||||
if (_cache.Count > CacheMax) { /* sweep */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
finally { _lock.ExitWriteLock(); }
|
||||
}
|
||||
internal readonly record struct RoutedSubKey(
|
||||
string RouteId,
|
||||
string Account,
|
||||
string Subject,
|
||||
string? Queue);
|
||||
```
|
||||
|
||||
On a read-lock cache hit, `Match()` returns immediately with no trie traversal. On a miss, `Tokenize()` splits the subject before acquiring the write lock (subjects with empty tokens return `SubListResult.Empty` immediately). The write lock is then taken and the cache is checked again before invoking `MatchLevel`.
|
||||
This replaces the earlier `"route|account|subject|queue"` composite string model. The change removes repeated string concatenation, `Split('|')`, and runtime reparsing in remote cleanup paths.
|
||||
|
||||
### `MatchLevel` traversal
|
||||
Remote-interest APIs:
|
||||
|
||||
`MatchLevel` is a recursive method that descends the trie matching tokens against the subject array. At each level, for each remaining token position:
|
||||
- `ApplyRemoteSub(...)` inserts or removes a `RemoteSubscription`
|
||||
- `UpdateRemoteQSub(...)` updates queue weight for an existing remote queue subscription
|
||||
- `RemoveRemoteSubs(routeId)` removes all remote interest for a disconnected route
|
||||
- `RemoveRemoteSubsForAccount(routeId, account)` removes only one route/account slice
|
||||
- `HasRemoteInterest(account, subject)` answers whether any remote subscription matches
|
||||
- `MatchRemote(account, subject)` returns the expanded weighted remote matches
|
||||
|
||||
1. If `level.Fwc` is set, all subscriptions from that node are added to the result. The `>` wildcard matches all remaining tokens, so no further recursion is needed for this branch.
|
||||
2. If `level.Pwc` is set, `MatchLevel` recurses with the next token index and `pwc.Next` as the new level. This handles `*` matching the current token.
|
||||
3. A literal dictionary lookup on `level.Nodes[tokens[i]]` advances the level pointer for the next iteration.
|
||||
Cleanup paths collect matching `RoutedSubKey` values into a reusable per-thread list and then remove them, avoiding `_remoteSubs.ToArray()` snapshots on every sweep.
|
||||
|
||||
After all tokens are consumed, subscriptions from the final literal node and the final `*` position (if present at the last level) are added to the result. The `*` case at the last token requires explicit handling because the loop exits before the recursive call for `*` can execute.
|
||||
---
|
||||
|
||||
`AddNodeToResults` flattens a node's `PlainSubs` into the accumulator list and merges its `QueueSubs` groups into the queue accumulator, combining groups by name across multiple matching nodes.
|
||||
## Match Pipeline
|
||||
|
||||
`Match(string subject)` is the hot path.
|
||||
|
||||
1. Increment `_matches`
|
||||
2. Read the current `_generation`
|
||||
3. Try the cache under a read lock
|
||||
4. On cache miss, tokenize the subject and retry under the write lock
|
||||
5. Traverse the trie and build a `SubListResult`
|
||||
6. Cache the result with the generation that produced it
|
||||
|
||||
Cached entries are stored as:
|
||||
|
||||
```csharp
|
||||
private readonly record struct CachedResult(SubListResult Result, long Generation);
|
||||
```
|
||||
|
||||
A cache entry is valid only if its stored generation matches the current `_generation`. Any local or remote-interest mutation increments `_generation`, so stale entries are ignored automatically.
|
||||
|
||||
### Match Builder
|
||||
|
||||
Cache misses use a reusable per-thread `MatchBuilder` instead of allocating fresh nested `List<List<Subscription>>` structures on every traversal. The builder:
|
||||
|
||||
- reuses a `List<Subscription>` for plain subscribers
|
||||
- reuses queue-group lists across matches
|
||||
- merges queue matches by queue name during traversal
|
||||
- materializes the public `SubListResult` arrays only once at the end
|
||||
|
||||
This keeps the public contract unchanged while removing temporary match-building churn from the publish path.
|
||||
|
||||
### Intentional Remaining Allocations
|
||||
|
||||
The current implementation still allocates in two places by design:
|
||||
|
||||
- the tokenized `string[]` produced by `Tokenize(subject)` on cache misses
|
||||
- the final `Subscription[]` and `Subscription[][]` arrays stored in `SubListResult`
|
||||
|
||||
Those allocations are part of the current public result shape and cache model.
|
||||
|
||||
---
|
||||
|
||||
## Cache Strategy
|
||||
|
||||
The cache is a `Dictionary<string, SubListResult>` keyed by the literal published subject. All operations use `StringComparer.Ordinal`.
|
||||
The cache is a `Dictionary<string, CachedResult>` keyed by literal publish subject with `StringComparer.Ordinal`.
|
||||
|
||||
**Size limits:** The cache holds at most `CacheMax` (1024) entries. When `_cache.Count` exceeds this, a sweep removes entries until the count reaches `CacheSweep` (256). The sweep takes the first `count - 256` keys from the dictionary — no LRU ordering is maintained.
|
||||
- `CacheMax = 1024`
|
||||
- `CacheSweep = 256`
|
||||
|
||||
**`AddToCache`** is called from `Insert()` to keep cached results consistent after adding a subscription:
|
||||
- For a literal subscription subject, `AddToCache` does a direct lookup. If the exact key is in the cache, it creates a new `SubListResult` with the subscription appended and replaces the cached entry.
|
||||
- For a wildcard subscription subject, `AddToCache` scans all cached keys and updates any entry whose key is matched by `SubjectMatch.MatchLiteral(key, subject)`.
|
||||
When the cache grows past `CacheMax`, `SubListCacheSweeper` schedules a sweep that removes enough keys to return to the target size. The sweep is intentionally simple; it is not LRU.
|
||||
|
||||
**`RemoveFromCache`** is called from `Remove()` to invalidate cached results after removing a subscription:
|
||||
- For a literal subscription subject, `RemoveFromCache` removes the exact cache key.
|
||||
- For a wildcard subscription subject, `RemoveFromCache` removes all cached keys matched by the pattern. Because it is difficult to reconstruct the correct result without a full trie traversal, invalidation is preferred over update.
|
||||
|
||||
The asymmetry between `AddToCache` (updates in place) and `RemoveFromCache` (invalidates) avoids a second trie traversal on removal at the cost of a cache miss on the next `Match()` for those keys.
|
||||
The cache stores fully materialized `SubListResult` instances because publish callers need stable array-based results immediately after lookup.
|
||||
|
||||
---
|
||||
|
||||
## Disposal
|
||||
## Statistics and Monitoring
|
||||
|
||||
`SubList` implements `IDisposable`. `Dispose()` releases the `ReaderWriterLockSlim`:
|
||||
`Stats()` exposes:
|
||||
|
||||
```csharp
|
||||
public void Dispose() => _lock.Dispose();
|
||||
```
|
||||
- subscription count
|
||||
- cache entry count
|
||||
- insert/remove/match counts
|
||||
- cache hit rate
|
||||
- fanout statistics derived from cached results
|
||||
|
||||
`SubList` instances are owned by `NatsServer` and disposed during server shutdown.
|
||||
These counters are used by tests and monitoring code to validate routing behavior and cache effectiveness.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Subscriptions Overview](../Subscriptions/Overview.md)
|
||||
- [Overview](Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
<!-- Last verified against codebase: 2026-03-13 -->
|
||||
|
||||
+162
-79
@@ -1,46 +1,10 @@
|
||||
# Go vs .NET NATS Server — Benchmark Comparison
|
||||
|
||||
Benchmark run: 2026-03-13 10:06 AM America/Indiana/Indianapolis. The latest refresh used the benchmark project README command (`dotnet test tests/NATS.Server.Benchmark.Tests --filter "Category=Benchmark" -v normal --logger "console;verbosity=detailed"`) and completed successfully as a `.NET`-only run. The Go/.NET comparison tables below remain the last Go-capable comparison baseline.
|
||||
Benchmark run: 2026-03-13 America/Indiana/Indianapolis. Both servers ran on the same machine using the benchmark project (`dotnet test tests/NATS.Server.Benchmark.Tests -c Release --filter "Category=Benchmark" -v normal --logger "console;verbosity=detailed"`). Tests run in two batches (core pub/sub, then everything else) to reduce cross-test resource contention.
|
||||
|
||||
**Environment:** Apple M4, .NET SDK 10.0.101, README benchmark command run in the benchmark project's default `Debug` configuration, Go toolchain installed but the current full-suite run emitted only `.NET` result blocks.
|
||||
**Environment:** Apple M4, .NET SDK 10.0.101, Release build (server GC, tiered PGO enabled), Go toolchain installed, Go reference server built from `golang/nats-server/`.
|
||||
|
||||
---
|
||||
|
||||
## Latest README Run (.NET only)
|
||||
|
||||
The current refresh came from `/tmp/bench-output.txt` using the benchmark project README workflow. Because the run did not emit any Go comparison blocks, the values below are the latest `.NET`-only numbers from that run, and the historical Go/.NET comparison tables are preserved below instead of being overwritten with mixed-source ratios.
|
||||
|
||||
### Core and JetStream
|
||||
|
||||
| Benchmark | .NET msg/s | .NET MB/s | Notes |
|
||||
|-----------|------------|-----------|-------|
|
||||
| Single Publisher (16B) | 1,392,442 | 21.2 | README full-suite run |
|
||||
| Single Publisher (128B) | 1,491,226 | 182.0 | README full-suite run |
|
||||
| PubSub 1:1 (16B) | 717,731 | 11.0 | README full-suite run |
|
||||
| PubSub 1:1 (16KB) | 28,450 | 444.5 | README full-suite run |
|
||||
| Fan-Out 1:4 (128B) | 1,451,748 | 177.2 | README full-suite run |
|
||||
| Multi 4Px4S (128B) | 244,878 | 29.9 | README full-suite run |
|
||||
| Request-Reply Single (128B) | 6,840 | 0.8 | P50 142.5 us, P99 203.9 us |
|
||||
| Request-Reply 10Cx2S (16B) | 22,844 | 0.3 | P50 421.1 us, P99 602.1 us |
|
||||
| JS Sync Publish (16B Memory) | 12,619 | 0.2 | README full-suite run |
|
||||
| JS Async Publish (128B File) | 46,631 | 5.7 | README full-suite run |
|
||||
| JS Ordered Consumer (128B) | 108,057 | 13.2 | README full-suite run |
|
||||
| JS Durable Fetch (128B) | 490,090 | 59.8 | README full-suite run |
|
||||
|
||||
### Parser Microbenchmarks
|
||||
|
||||
| Benchmark | Ops/s | MB/s | Alloc |
|
||||
|-----------|-------|------|-------|
|
||||
| Parser PING | 5,756,370 | 32.9 | 0.0 B/op |
|
||||
| Parser PUB | 2,537,973 | 96.8 | 40.0 B/op |
|
||||
| Parser HPUB | 2,298,811 | 122.8 | 40.0 B/op |
|
||||
| Parser PUB split payload | 2,049,535 | 78.2 | 176.0 B/op |
|
||||
|
||||
### Current Run Highlights
|
||||
|
||||
1. The parser microbenchmarks show the hot path is already at zero allocation for `PING`, with contiguous `PUB` and `HPUB` still paying a small fixed cost for retained field copies.
|
||||
2. Split-payload `PUB` remains meaningfully more allocation-heavy than contiguous `PUB` because the parser must preserve unread payload state across reads and then materialize contiguous memory at the current client boundary.
|
||||
3. The README-driven suite was a `.NET`-only refresh, so the comparative Go/.NET ratios below should still be treated as the last Go-capable baseline rather than current same-run ratios.
|
||||
> **Note on variance:** Some benchmarks (especially those completing in <100ms) show significant run-to-run variance. The message counts were increased from the original values to improve stability, but some tests remain short enough to be sensitive to JIT warmup, GC timing, and OS scheduling.
|
||||
|
||||
---
|
||||
|
||||
@@ -50,27 +14,27 @@ The current refresh came from `/tmp/bench-output.txt` using the benchmark projec
|
||||
|
||||
| Payload | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|---------|----------|---------|------------|-----------|-----------------|
|
||||
| 16 B | 2,252,242 | 34.4 | 1,610,807 | 24.6 | 0.72x |
|
||||
| 128 B | 2,199,267 | 268.5 | 1,661,014 | 202.8 | 0.76x |
|
||||
| 16 B | 2,162,959 | 33.0 | 1,602,442 | 24.5 | 0.74x |
|
||||
| 128 B | 3,773,858 | 460.7 | 1,408,294 | 171.9 | 0.37x |
|
||||
|
||||
### Publisher + Subscriber (1:1)
|
||||
|
||||
| Payload | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|---------|----------|---------|------------|-----------|-----------------|
|
||||
| 16 B | 313,790 | 4.8 | 909,298 | 13.9 | **2.90x** |
|
||||
| 16 KB | 41,153 | 643.0 | 38,287 | 598.2 | 0.93x |
|
||||
| 16 B | 1,075,095 | 16.4 | 713,952 | 10.9 | 0.66x |
|
||||
| 16 KB | 39,215 | 612.7 | 30,916 | 483.1 | 0.79x |
|
||||
|
||||
### Fan-Out (1 Publisher : 4 Subscribers)
|
||||
|
||||
| Payload | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|---------|----------|---------|------------|-----------|-----------------|
|
||||
| 128 B | 3,217,684 | 392.8 | 1,817,860 | 221.9 | 0.57x |
|
||||
| 128 B | 2,919,353 | 356.4 | 2,459,924 | 300.3 | 0.84x |
|
||||
|
||||
### Multi-Publisher / Multi-Subscriber (4P x 4S)
|
||||
|
||||
| Payload | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|---------|----------|---------|------------|-----------|-----------------|
|
||||
| 128 B | 2,101,337 | 256.5 | 1,527,330 | 186.4 | 0.73x |
|
||||
| 128 B | 1,870,855 | 228.4 | 1,892,631 | 231.0 | **1.01x** |
|
||||
|
||||
---
|
||||
|
||||
@@ -78,15 +42,15 @@ The current refresh came from `/tmp/bench-output.txt` using the benchmark projec
|
||||
|
||||
### Single Client, Single Service
|
||||
|
||||
| Payload | Go msg/s | .NET msg/s | Ratio | Go P50 (us) | .NET P50 (us) | Go P99 (us) | .NET P99 (us) |
|
||||
|---------|----------|------------|-------|-------------|---------------|-------------|---------------|
|
||||
| 128 B | 9,450 | 7,662 | 0.81x | 103.2 | 128.9 | 145.6 | 170.8 |
|
||||
| Payload | Go msg/s | .NET msg/s | Ratio |
|
||||
|---------|----------|------------|-------|
|
||||
| 128 B | 9,392 | 8,372 | 0.89x |
|
||||
|
||||
### 10 Clients, 2 Services (Queue Group)
|
||||
|
||||
| Payload | Go msg/s | .NET msg/s | Ratio | Go P50 (us) | .NET P50 (us) | Go P99 (us) | .NET P99 (us) |
|
||||
|---------|----------|------------|-------|-------------|---------------|-------------|---------------|
|
||||
| 16 B | 31,094 | 26,144 | 0.84x | 316.9 | 368.7 | 439.2 | 559.7 |
|
||||
| Payload | Go msg/s | .NET msg/s | Ratio |
|
||||
|---------|----------|------------|-------|
|
||||
| 16 B | 30,563 | 26,178 | 0.86x |
|
||||
|
||||
---
|
||||
|
||||
@@ -94,10 +58,8 @@ The current refresh came from `/tmp/bench-output.txt` using the benchmark projec
|
||||
|
||||
| Mode | Payload | Storage | Go msg/s | .NET msg/s | Ratio (.NET/Go) |
|
||||
|------|---------|---------|----------|------------|-----------------|
|
||||
| Synchronous | 16 B | Memory | 17,533 | 14,373 | 0.82x |
|
||||
| Async (batch) | 128 B | File | 198,237 | 60,416 | 0.30x |
|
||||
|
||||
> **Note:** Async file store publish improved from 174 msg/s to 60K msg/s (347x improvement) after two rounds of FileStore-level optimizations plus profiling overhead removal. Remaining 3.3x gap is GC pressure from per-message allocations.
|
||||
| Synchronous | 16 B | Memory | 16,982 | 14,514 | 0.85x |
|
||||
| Async (batch) | 128 B | File | 174,421 | 85,394 | 0.49x |
|
||||
|
||||
---
|
||||
|
||||
@@ -105,41 +67,161 @@ The current refresh came from `/tmp/bench-output.txt` using the benchmark projec
|
||||
|
||||
| Mode | Go msg/s | .NET msg/s | Ratio (.NET/Go) |
|
||||
|------|----------|------------|-----------------|
|
||||
| Ordered ephemeral consumer | 748,671 | 114,021 | 0.15x |
|
||||
| Durable consumer fetch | 662,471 | 488,520 | 0.74x |
|
||||
| Ordered ephemeral consumer | 786,681 | 346,162 | 0.44x |
|
||||
| Durable consumer fetch | 711,203 | 542,250 | 0.76x |
|
||||
|
||||
> **Note:** Durable fetch improved from 0.13x → 0.60x → **0.74x** after Round 6 optimizations (batch flush, ackReply stack formatting, cached CompiledFilter, pooled fetch list). Ordered consumer ratio dropped due to Go benchmark improvement (748K vs 156K in earlier runs); .NET throughput is stable at ~110K msg/s.
|
||||
---
|
||||
|
||||
## MQTT Throughput
|
||||
|
||||
| Benchmark | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|-----------|----------|---------|------------|-----------|-----------------|
|
||||
| MQTT PubSub (128B, QoS 0) | 36,913 | 4.5 | 48,755 | 6.0 | **1.32x** |
|
||||
| Cross-Protocol NATS→MQTT (128B) | 407,487 | 49.7 | 287,946 | 35.1 | 0.71x |
|
||||
|
||||
---
|
||||
|
||||
## Transport Overhead
|
||||
|
||||
### TLS
|
||||
|
||||
| Benchmark | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|-----------|----------|---------|------------|-----------|-----------------|
|
||||
| TLS PubSub 1:1 (128B) | 244,403 | 29.8 | 1,148,179 | 140.2 | **4.70x** |
|
||||
| TLS Pub-Only (128B) | 3,224,490 | 393.6 | 1,246,351 | 152.1 | 0.39x |
|
||||
|
||||
> **Note:** TLS PubSub 1:1 shows .NET dramatically outperforming Go (4.70x). This appears to reflect .NET's `SslStream` having lower per-message overhead when both publishing and subscribing over TLS. The TLS pub-only benchmark (no subscriber, pure ingest) shows Go significantly faster at 0.39x, suggesting the Go server's raw TLS write throughput is higher but its read+deliver path has more overhead.
|
||||
|
||||
### WebSocket
|
||||
|
||||
| Benchmark | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|-----------|----------|---------|------------|-----------|-----------------|
|
||||
| WS PubSub 1:1 (128B) | 44,783 | 5.5 | 40,793 | 5.0 | 0.91x |
|
||||
| WS Pub-Only (128B) | 118,898 | 14.5 | 100,522 | 12.3 | 0.85x |
|
||||
|
||||
---
|
||||
|
||||
## Hot Path Microbenchmarks (.NET only)
|
||||
|
||||
### SubList
|
||||
|
||||
| Benchmark | .NET msg/s | .NET MB/s | Alloc |
|
||||
|-----------|------------|-----------|-------|
|
||||
| SubList Exact Match (128 subjects) | 22,812,300 | 304.6 | 0.00 B/op |
|
||||
| SubList Wildcard Match | 17,626,363 | 235.3 | 0.00 B/op |
|
||||
| SubList Queue Match | 23,306,329 | 177.8 | 0.00 B/op |
|
||||
| SubList Remote Interest | 437,080 | 7.1 | 0.00 B/op |
|
||||
|
||||
### Parser
|
||||
|
||||
| Benchmark | Ops/s | MB/s | Alloc |
|
||||
|-----------|-------|------|-------|
|
||||
| Parser PING | 6,262,196 | 35.8 | 0.0 B/op |
|
||||
| Parser PUB | 2,663,706 | 101.6 | 40.0 B/op |
|
||||
| Parser HPUB | 2,213,655 | 118.2 | 40.0 B/op |
|
||||
| Parser PUB split payload | 2,100,256 | 80.1 | 176.0 B/op |
|
||||
|
||||
### FileStore
|
||||
|
||||
| Benchmark | Ops/s | MB/s | Alloc |
|
||||
|-----------|-------|------|-------|
|
||||
| FileStore AppendAsync (128B) | 275,438 | 33.6 | 1242.9 B/op |
|
||||
| FileStore LoadLastBySubject (hot) | 1,138,203 | 69.5 | 656.0 B/op |
|
||||
| FileStore PurgeEx+Trim | 647 | 0.1 | 5440579.9 B/op |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Ratio Range | Assessment |
|
||||
|----------|-------------|------------|
|
||||
| Pub-only throughput | 0.72x–0.76x | Good — within 2x |
|
||||
| Pub/sub (small payload) | **2.90x** | .NET outperforms Go — direct buffer path eliminates all per-message overhead |
|
||||
| Pub/sub (large payload) | 0.93x | Near parity |
|
||||
| Fan-out | 0.57x | Improved from 0.18x → 0.44x → 0.66x; batch flush applied but serial delivery remains |
|
||||
| Multi pub/sub | 0.73x | Improved from 0.49x → 0.84x; variance from system load |
|
||||
| Request/reply latency | 0.81x–0.84x | Good — improved from 0.77x |
|
||||
| JetStream sync publish | 0.82x | Good |
|
||||
| JetStream async file publish | 0.30x | Improved from 0.00x — storage write path dominates |
|
||||
| JetStream ordered consume | 0.15x | .NET stable ~110K; Go variance high (156K–749K) |
|
||||
| JetStream durable fetch | **0.74x** | **Improved from 0.60x** — batch flush + ackReply optimization |
|
||||
| Category | Ratio | Assessment |
|
||||
|----------|-------|------------|
|
||||
| Pub-only throughput (16B) | 0.74x | Stable across runs |
|
||||
| Pub-only throughput (128B) | 0.37x | Go significantly faster at larger payloads |
|
||||
| Pub/sub 1:1 (16B) | 0.66x | Go ahead; high variance at short durations |
|
||||
| Pub/sub 1:1 (16KB) | 0.79x | Reasonable gap |
|
||||
| Fan-out 1:4 | 0.84x | Improved after Round 10 optimizations |
|
||||
| Multi pub/sub 4x4 | **1.01x** | At parity |
|
||||
| Request/reply (single) | 0.89x | Close to parity |
|
||||
| Request/reply (10Cx2S) | 0.86x | Close to parity |
|
||||
| JetStream sync publish | 0.85x | Close to parity |
|
||||
| JetStream async file publish | 0.49x | Improved after double-buffer + deferred fsync |
|
||||
| JetStream ordered consume | 0.44x | Significant gap |
|
||||
| JetStream durable fetch | 0.76x | Moderate gap |
|
||||
| MQTT pub/sub | **1.32x** | .NET outperforms Go |
|
||||
| MQTT cross-protocol | 0.71x | Go ahead; high variance |
|
||||
| TLS pub/sub | **4.70x** | .NET SslStream dramatically faster |
|
||||
| TLS pub-only | 0.39x | Go raw TLS write faster |
|
||||
| WebSocket pub/sub | 0.91x | Close to parity |
|
||||
| WebSocket pub-only | 0.85x | Good |
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Small-payload 1:1 pub/sub outperforms Go by ~3x** (909K vs 314K msg/s). The per-client direct write buffer with `stackalloc` header formatting eliminates all per-message heap allocations and channel overhead.
|
||||
2. **Durable consumer fetch improved to 0.74x** (489K vs 662K msg/s) — Round 6 batch flush signaling and `string.Create`-based ack reply formatting reduced per-message overhead significantly.
|
||||
3. **Fan-out holds at ~0.57x** despite batch flush optimization. The remaining gap is goroutine-level parallelism (Go fans out per-client via goroutines; .NET delivers serially). The batch flush reduces wakeup overhead but doesn't add concurrency.
|
||||
4. **Request/reply improved to 0.81x–0.84x** — deferred flush benefits single-message delivery paths too.
|
||||
5. **JetStream file store async publish: 0.30x** — remaining gap is GC pressure from per-message `StoredMessage` objects and `byte[]` copies (Change 2 deferred due to scope: 80+ sites in FileStore.cs need migration).
|
||||
6. **JetStream ordered consumer: 0.15x** — ratio drop is due to Go benchmark variance (749K in this run vs 156K previously); .NET throughput stable at ~110K msg/s. Further investigation needed for the Go variability.
|
||||
1. **Multi pub/sub reached parity (1.01x)** after Round 10 pre-formatted MSG headers. Fan-out improved to 0.84x.
|
||||
2. **JetStream async file publish improved to 0.49x** (from 0.28x) after Round 11 double-buffer + deferred fsync optimizations — a 75% improvement.
|
||||
3. **TLS pub/sub shows a dramatic .NET advantage (4.70x)** — .NET's `SslStream` has significantly lower overhead in the bidirectional pub/sub path. TLS pub-only (ingest only) still favors Go at 0.39x, suggesting the advantage is in the read-and-deliver path.
|
||||
4. **MQTT pub/sub remains a .NET strength at 1.32x.** Cross-protocol (NATS→MQTT) dropped to 0.71x — this benchmark shows high variance across runs.
|
||||
5. **JetStream ordered consumer dropped to 0.44x** compared to earlier runs (0.62x). This test completes in <100ms and shows high variance.
|
||||
6. **Single publisher 128B dropped to 0.37x** (from 0.62x with smaller message counts). With 500K messages, this benchmark runs long enough for Go's goroutine scheduler and buffer management to reach steady state, widening the gap. The 16B variant is stable at 0.74x.
|
||||
7. **Request-reply latency stable** at 0.86x–0.89x across all runs.
|
||||
|
||||
---
|
||||
|
||||
## Optimization History
|
||||
|
||||
### Round 11: JetStream FileStore Double-Buffer + Deferred Fsync
|
||||
|
||||
Two optimizations targeting the JetStream async file publish hot path (0.28x→0.49x, 75% improvement):
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 41 | **Lock contention between WriteAt and FlushPending** — `MsgBlock.FlushPending()` held the write lock for the entire `RandomAccess.Write` call, blocking `WriteAt` (publish path) during disk I/O | Double-buffer: swap `_pendingBuf` ↔ `_flushBuf` under write lock, then write old buffer to disk outside lock using separate `_flushLock`; publish path only blocked during buffer pointer swap, not disk I/O | Eliminates write-lock contention during disk I/O |
|
||||
| 42 | **Synchronous fsync on publish path** — `RotateBlock()` called `FlushToDisk()` which did `fsync` synchronously (1,557ms per profile), blocking the publish hot path for every block rotation | Deferred fsync: `RotateBlock` enqueues completed blocks into `ConcurrentQueue<MsgBlock> _needSyncBlocks`; background `FlushLoopAsync` drains the queue via `DrainSyncQueue()`, calling `Flush()` (fsync) off the publish path — matches Go's `needSync` flag + background goroutine pattern | Moves fsync entirely off the publish hot path |
|
||||
|
||||
### Round 10: Fan-Out Serial Path Optimization
|
||||
|
||||
Three optimizations making the serial fan-out path cheaper (fan-out 0.63x→0.84x, multi 0.65x→1.01x):
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 38 | **Per-delivery MSG header re-formatting** — `SendMessageNoFlush` independently formats the entire MSG header line (prefix, subject copy, replyTo encoding, size formatting, CRLF) for every subscriber — but only the SID varies per delivery | Pre-build prefix (`MSG subject `) and suffix (` [reply] sizes\r\n`) once per publish; new `SendMessagePreformatted` writes prefix+sid+suffix directly into `_directBuf` — zero encoding, pure memory copies | Eliminates per-delivery replyTo encoding, size formatting, prefix/subject copying |
|
||||
| 39 | **Queue-group round-robin burns 2 Interlocked ops** — `Interlocked.Increment(ref OutMsgs)` + `Interlocked.Decrement(ref OutMsgs)` per queue group just to pick an index | Replaced with non-atomic `uint QueueRoundRobin++` — safe because ProcessMessage runs single-threaded per publisher connection (the read loop) | Eliminates 2 interlocked ops per queue group per publish |
|
||||
| 40 | **`HashSet<INatsClient>` pcd overhead** — hash computation + bucket lookup per Add for small fan-out counts (4 subscribers) | Replaced with `[ThreadStatic] INatsClient[]` + linear scan; O(n) but n≤16, faster than hash for small counts | Eliminates hash computation and internal array overhead |
|
||||
|
||||
### Round 9: Fan-Out & Multi Pub/Sub Hot-Path Optimization
|
||||
|
||||
Seven optimizations targeting the per-delivery hot path and benchmark harness configuration:
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 31 | **Benchmark harness built server in Debug** — `DotNetServerProcess.cs` hardcoded `-c Debug`, disabling JIT optimizations, tiered PGO, and inlining | Changed to `-c Release` build and DLL path | Major: durable fetch 0.42x→0.92x, request-reply to parity |
|
||||
| 32 | **Per-delivery Interlocked on server-wide stats** — `SendMessageNoFlush` did 2 `Interlocked` ops per delivery; fan-out 4 subs = 8 interlocked ops per publish | Moved server-wide stats to batch `Interlocked.Add` once after fan-out loop in `ProcessMessage` | Eliminates N×2 interlocked ops per publish |
|
||||
| 33 | **Auto-unsub tracking on every delivery** — `Interlocked.Increment(ref sub.MessageCount)` on every delivery even when `MaxMessages == 0` (no limit — the common case) | Guarded with `if (sub.MaxMessages > 0)` | Eliminates 1 interlocked op per delivery in common case |
|
||||
| 34 | **Per-delivery SID ASCII encoding** — `Encoding.ASCII.GetBytes(sid)` on every delivery; SID is a small integer that never changes | Added `Subscription.SidBytes` cached property; new `SendMessageNoFlush` overload accepts `ReadOnlySpan<byte>` | Eliminates per-delivery encoding |
|
||||
| 35 | **Per-delivery subject ASCII encoding** — `Encoding.ASCII.GetBytes(subject)` for each subscriber; fan-out 4 = 4× encoding same subject | Pre-encode subject once in `ProcessMessage` before fan-out loop; new overload uses span copy | Eliminates N-1 subject encodings per publish |
|
||||
| 36 | **Per-publish subject string allocation** — `Encoding.ASCII.GetString(cmd.Subject.Span)` on every PUB even when publishing to the same subject repeatedly | Added 1-element string cache per client; reuses string when subject bytes match | Eliminates string alloc for repeated subjects |
|
||||
| 37 | **Interlocked stats in SubList.Match hot path** — `Interlocked.Increment(ref _matches)` and `_cacheHits` on every match call | Replaced with non-atomic increments (approximate counters for monitoring) | Eliminates 1-2 interlocked ops per match |
|
||||
|
||||
### Round 8: Ordered Consumer + Cross-Protocol Optimization
|
||||
|
||||
Three optimizations targeting pull consumer delivery and MQTT cross-protocol throughput:
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 28 | **Per-message flush signal in DeliverPullFetchMessagesAsync** — `DeliverMessage` called `SendMessage` which triggered `_flushSignal.Writer.TryWrite(0)` per message; for batch of N messages, N flush signals and write-loop wakeups | Replaced with `SendMessageNoFlush` + batch flush every 64 messages + final flush after loop; bypasses `DeliverMessage` entirely (no permission check / auto-unsub needed for JS delivery inbox) | Reduces flush signals from N to N/64 per batch |
|
||||
| 29 | **5ms polling delay in pull consumer wait loop** — `Task.Delay(5)` in `DeliverPullFetchMessagesAsync` and `PullConsumerEngine.WaitForMessageAsync` added up to 5ms latency per empty slot; for tail-following consumers, every new message waited up to 5ms to be noticed | Added `StreamHandle.NotifyPublish()` / `WaitForPublishAsync()` using `TaskCompletionSource` signaling; publishers call `NotifyPublish` after `AppendAsync`; consumers wait on signal with heartbeat-interval timeout | Eliminates polling delay; instant wakeup on publish |
|
||||
| 30 | **StringBuilder allocation in NatsToMqtt for common case** — every uncached `NatsToMqtt` call allocated a StringBuilder even when no `_DOT_` escape sequences were present (the common case) | Added `string.Create` fast path that uses char replacement lambda when no `_DOT_` found; pre-warm topic bytes cache on MQTT subscription creation | Eliminates StringBuilder + string alloc for common case; no cache miss on first delivery |
|
||||
|
||||
### Round 7: MQTT Cross-Protocol Write Path
|
||||
|
||||
Four optimizations targeting the NATS→MQTT delivery hot path (cross-protocol throughput improved from 0.30x to 0.78x):
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 24 | **Per-message async fire-and-forget in MqttNatsClientAdapter** — each `SendMessage` called `SendBinaryPublishAsync` which acquired a `SemaphoreSlim`, allocated a full PUBLISH packet `byte[]`, wrote, and flushed the stream — all per message, bypassing the server's deferred-flush batching | Replaced with synchronous `EnqueuePublishNoFlush()` that formats MQTT PUBLISH directly into `_directBuf` under SpinLock, matching the NatsClient pattern; `SignalFlush()` signals the write loop for batch flush | Eliminates async Task + SemaphoreSlim + per-message flush |
|
||||
| 25 | **Per-message `byte[]` allocation for MQTT PUBLISH packets** — `MqttPacketWriter.WritePublish()` allocated topic bytes, variable header, remaining-length array, and full packet array on every delivery | Added `WritePublishTo(Span<byte>)` that formats the entire PUBLISH packet directly into the destination span using `Span<byte>` operations — zero heap allocation | Eliminates 4+ `byte[]` allocs per delivery |
|
||||
| 26 | **Per-message NATS→MQTT topic translation** — `NatsToMqtt()` allocated a `StringBuilder`, produced a `string`, then `Encoding.UTF8.GetBytes()` re-encoded it on every delivery | Added `NatsToMqttBytes()` with bounded `ConcurrentDictionary<string, byte[]>` cache (4096 entries); cached result includes pre-encoded UTF-8 bytes | Eliminates string + encoding alloc per delivery for cached topics |
|
||||
| 27 | **Per-message `FlushAsync` on plain TCP sockets** — `WriteBinaryAsync` flushed after every packet write, even on `NetworkStream` where TCP auto-flushes | Write loop skips `FlushAsync` for plain sockets; for TLS/wrapped streams, flushes once per batch (not per message) | Reduces syscalls from 2N to 1 per batch |
|
||||
|
||||
### Round 6: Batch Flush Signaling + Fetch Optimizations
|
||||
|
||||
Four optimizations targeting fan-out and consumer fetch hot paths:
|
||||
@@ -207,6 +289,7 @@ Additional fixes: SHA256 envelope bypass for unencrypted/uncompressed stores, RA
|
||||
|
||||
| Change | Expected Impact | Go Reference |
|
||||
|--------|----------------|-------------|
|
||||
| **Fan-out parallelism** | Deliver to subscribers concurrently instead of serially from publisher's read loop | Go: `processMsgResults` fans out per-client via goroutines |
|
||||
| **Eliminate per-message GC allocations in FileStore** | ~30% improvement on FileStore AppendAsync — replace `StoredMessage` class with `StoredMessageMeta` struct in `_messages` dict, reconstruct full message from MsgBlock on read | Go stores in `cache.buf`/`cache.idx` with zero per-message allocs; 80+ sites in FileStore.cs need migration |
|
||||
| **Ordered consumer delivery optimization** | Investigate .NET ordered consumer throughput ceiling (~110K msg/s) vs Go's variable 156K–749K | Go: consumer.go ordered consumer fast path |
|
||||
| **Single publisher ingest path (0.37x at 128B)** | The pub-only path has the largest gap. Go's readLoop uses zero-copy buffer management with direct `[]byte` slicing; .NET parses into managed objects. Reducing allocations in the parser→ProcessMessage path would help. | Go: `client.go` readLoop, direct buffer slicing |
|
||||
| **JetStream async file publish (0.49x)** | After double-buffer + deferred fsync, remaining gap is likely write coalescing and S2 compression overhead | Go: `filestore.go` uses `cache.buf`/`cache.idx` with mmap and goroutine-per-flush concurrency |
|
||||
| **JetStream ordered consumer (0.44x)** | Pull consumer delivery pipeline has overhead in the fetch→deliver→ack cycle. The test completes in <100ms so numbers are noisy, but the gap is real. | Go: `consumer.go` delivery with direct buffer writes |
|
||||
| **Write-loop / socket write overhead** | Fan-out (0.84x) and pub/sub (0.66x) gaps partly come from write-loop wakeup latency and socket write syscall overhead compared to Go's `writev()` | Go: `flushOutbound` uses `net.Buffers.WriteTo` → `writev()` with zero-copy buffer management |
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# FileStore Payload And Index Optimization Implementation Plan
|
||||
|
||||
> **For Codex:** REQUIRED SUB-SKILLS: Use `using-git-worktrees` to create an isolated workspace before Task 1, then use `executeplan` to implement this plan task-by-task. After verification is complete, merge the finished branch back into `main`.
|
||||
|
||||
**Goal:** Reduce JetStream FileStore memory churn and repeated full scans by tightening payload ownership, splitting compact metadata from large payload buffers, and replacing LINQ-based maintenance work with explicit indexes and loops.
|
||||
|
||||
**Architecture:** Start by freezing current behavior across `AppendAsync`, `StoreMsg`, retention, snapshots, and recovery. Then introduce compact metadata/index structures, remove avoidable duplicate payload buffers, replace repeated `_messages` scans with maintained indexes, and finish by updating recovery/snapshot paths plus benchmark coverage.
|
||||
|
||||
**Tech Stack:** .NET 10, C#, JetStream storage stack, `ReadOnlyMemory<byte>`, pooled buffers where safe, xUnit, existing JetStream benchmark harness.
|
||||
|
||||
---
|
||||
|
||||
## Scope Anchors
|
||||
- Primary source: `src/NATS.Server/JetStream/Storage/FileStore.cs`
|
||||
- Supporting sources:
|
||||
- `src/NATS.Server/JetStream/Storage/MsgBlock.cs`
|
||||
- `src/NATS.Server/JetStream/Storage/StoredMessage.cs`
|
||||
- `src/NATS.Server/JetStream/Storage/MessageRecord.cs`
|
||||
- Existing contract tests:
|
||||
- `tests/NATS.Server.JetStream.Tests/StreamStoreContractTests.cs`
|
||||
- `tests/NATS.Server.JetStream.Tests/JetStream/Storage/StoreInterfaceTests.cs`
|
||||
- Existing FileStore coverage:
|
||||
- `tests/NATS.Server.JetStream.Tests/FileStoreTests.cs`
|
||||
- `tests/NATS.Server.JetStream.Tests/JetStreamStoreIndexTests.cs`
|
||||
- `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreCompressionTests.cs`
|
||||
- `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreCrashRecoveryTests.cs`
|
||||
- `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreTombstoneTests.cs`
|
||||
- Documentation to update: `Documentation/JetStream/Overview.md`
|
||||
- Benchmark project: `tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj`
|
||||
- Benchmark comparison doc: `benchmarks_comparison.md`
|
||||
|
||||
## Task 0: Create an isolated git worktree and verify the baseline
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore` only if the chosen local worktree directory is not already ignored
|
||||
|
||||
**Step 1: Choose the worktree location using the repo convention**
|
||||
- Check for an existing `.worktrees/` directory first, then `worktrees/`.
|
||||
- If neither exists, check repo guidance before creating one.
|
||||
- Prefer a project-local `.worktrees/` directory when available.
|
||||
|
||||
**Step 2: Verify the worktree directory is ignored before creating anything**
|
||||
- Run:
|
||||
```bash
|
||||
git check-ignore -q .worktrees || git check-ignore -q worktrees
|
||||
```
|
||||
- Expected: one configured worktree directory is ignored.
|
||||
- If neither directory is ignored, add the chosen directory to `.gitignore`, commit that change on `main`, and then continue.
|
||||
|
||||
**Step 3: Create a dedicated branch and worktree for this plan**
|
||||
- Run:
|
||||
```bash
|
||||
git worktree add .worktrees/filestore-payload-index-optimization -b codex/filestore-payload-index-optimization
|
||||
```
|
||||
- Expected: a new isolated checkout exists at `.worktrees/filestore-payload-index-optimization`.
|
||||
|
||||
**Step 4: Move into the worktree and verify the starting baseline**
|
||||
- Run:
|
||||
```bash
|
||||
cd .worktrees/filestore-payload-index-optimization
|
||||
dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj -c Release
|
||||
```
|
||||
- Expected: PASS before implementation starts.
|
||||
- If the baseline fails, stop and resolve whether to proceed before changing FileStore code.
|
||||
|
||||
**Step 5: Commit only the worktree bootstrap change if one was required**
|
||||
- Run only if `.gitignore` had to change:
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: ignore local worktree directory"
|
||||
```
|
||||
|
||||
## Task 1: Freeze store behavior and add scan/ownership regression tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/NATS.Server.JetStream.Tests/JetStreamStoreIndexTests.cs`
|
||||
- Modify: `tests/NATS.Server.JetStream.Tests/JetStream/Storage/StoreInterfaceTests.cs`
|
||||
- Create: `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreOptimizationGuardTests.cs`
|
||||
|
||||
**Step 1: Add failing tests for the targeted optimization boundaries**
|
||||
- Cover:
|
||||
- `AppendAsync` retaining logical payload behavior
|
||||
- `StoreMsg` with headers + payload
|
||||
- `LoadLastBySubjectAsync`
|
||||
- `TrimToMaxMessages`
|
||||
- `PurgeEx`
|
||||
- snapshot/recovery round-trips
|
||||
|
||||
**Step 2: Add tests that lock first/last sequence bookkeeping**
|
||||
- Ensure `_firstSeq`, `_last`, and subject-last lookup behavior remain correct after removes, purges, compaction, and recovery.
|
||||
|
||||
**Step 3: Run focused JetStream tests to prove the new tests fail first**
|
||||
- Run: `dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj --filter "FullyQualifiedName~FileStoreOptimizationGuardTests|FullyQualifiedName~JetStreamStoreIndexTests|FullyQualifiedName~StoreInterfaceTests" -c Release`
|
||||
- Expected: FAIL only in the newly added optimization-guard tests.
|
||||
|
||||
**Step 4: Commit the failing-test baseline**
|
||||
- Run:
|
||||
```bash
|
||||
git add tests/NATS.Server.JetStream.Tests/JetStreamStoreIndexTests.cs tests/NATS.Server.JetStream.Tests/JetStream/Storage/StoreInterfaceTests.cs tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreOptimizationGuardTests.cs
|
||||
git commit -m "test: lock FileStore optimization boundaries"
|
||||
```
|
||||
|
||||
## Task 2: Introduce compact metadata/index types and remove full-scan bookkeeping
|
||||
|
||||
**Files:**
|
||||
- Create: `src/NATS.Server/JetStream/Storage/StoredMessageIndex.cs`
|
||||
- Modify: `src/NATS.Server/JetStream/Storage/FileStore.cs`
|
||||
- Modify: `src/NATS.Server/JetStream/Storage/StoredMessage.cs`
|
||||
|
||||
**Step 1: Split compact indexing metadata from payload-bearing message objects**
|
||||
- Add a small immutable metadata/index type that tracks at least:
|
||||
- sequence
|
||||
- subject
|
||||
- logical payload length
|
||||
- timestamp
|
||||
- subject-local links or last-seen markers if needed
|
||||
|
||||
**Step 2: Replace repeated `Min()` / `Max()` / full-value scans with maintained state**
|
||||
- Maintain first live sequence, last live sequence, and last-by-subject values incrementally rather than recomputing them with LINQ.
|
||||
|
||||
**Step 3: Run targeted index tests**
|
||||
- Run: `dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj --filter "FullyQualifiedName~JetStreamStoreIndexTests|FullyQualifiedName~FileStoreOptimizationGuardTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit the metadata/index layer**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/JetStream/Storage/StoredMessageIndex.cs src/NATS.Server/JetStream/Storage/FileStore.cs src/NATS.Server/JetStream/Storage/StoredMessage.cs
|
||||
git commit -m "perf: add compact FileStore index metadata"
|
||||
```
|
||||
|
||||
## Task 3: Remove duplicate payload ownership in append and store paths
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/NATS.Server/JetStream/Storage/FileStore.cs`
|
||||
- Modify: `src/NATS.Server/JetStream/Storage/MsgBlock.cs`
|
||||
- Modify: `src/NATS.Server/JetStream/Storage/MessageRecord.cs`
|
||||
- Modify: `tests/NATS.Server.JetStream.Tests/FileStoreTests.cs`
|
||||
|
||||
**Step 1: Rework `AppendAsync` and `StoreMsg` payload flow**
|
||||
- Stop eagerly keeping both a transformed persisted payload and a second fully duplicated managed payload when the same buffer/view can safely back both responsibilities.
|
||||
- Keep correctness for compression, encryption, and header-bearing records explicit.
|
||||
|
||||
**Step 2: Remove concatenated header+payload arrays where possible**
|
||||
- Let record encoding paths consume header and payload spans directly instead of always building `combined = new byte[...]`.
|
||||
- Leave a copy in place only where the persistence or recovery contract actually requires one.
|
||||
|
||||
**Step 3: Run targeted persistence tests**
|
||||
- Run: `dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj --filter "FullyQualifiedName~FileStoreTests|FullyQualifiedName~FileStoreCompressionTests|FullyQualifiedName~FileStoreEncryptionTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit the payload-ownership refactor**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/JetStream/Storage/FileStore.cs src/NATS.Server/JetStream/Storage/MsgBlock.cs src/NATS.Server/JetStream/Storage/MessageRecord.cs tests/NATS.Server.JetStream.Tests/FileStoreTests.cs
|
||||
git commit -m "perf: reduce FileStore duplicate payload buffers"
|
||||
```
|
||||
|
||||
## Task 4: Replace LINQ-heavy maintenance operations with explicit indexed paths
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/NATS.Server/JetStream/Storage/FileStore.cs`
|
||||
- Modify: `tests/NATS.Server.JetStream.Tests/JetStream/Storage/StoreInterfaceTests.cs`
|
||||
- Modify: `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreCrashRecoveryTests.cs`
|
||||
- Modify: `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreTombstoneTests.cs`
|
||||
|
||||
**Step 1: Rewrite hot maintenance methods**
|
||||
- Replace LINQ-based implementations in:
|
||||
- `LoadLastBySubjectAsync`
|
||||
- `TrimToMaxMessages`
|
||||
- `PurgeEx`
|
||||
- snapshot/recovery recomputation paths
|
||||
- Use explicit loops and maintained indexes first; only add more elaborate per-subject structures if profiling still demands them.
|
||||
|
||||
**Step 2: Preserve recovery and tombstone correctness**
|
||||
- Verify delete markers, TTL rebuilds, compaction, and sequence-gap handling still match the current parity tests.
|
||||
|
||||
**Step 3: Run targeted JetStream storage suites**
|
||||
- Run: `dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj --filter "FullyQualifiedName~StoreInterfaceTests|FullyQualifiedName~FileStoreCrashRecoveryTests|FullyQualifiedName~FileStoreTombstoneTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit the maintenance-path rewrite**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/JetStream/Storage/FileStore.cs tests/NATS.Server.JetStream.Tests/JetStream/Storage/StoreInterfaceTests.cs tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreCrashRecoveryTests.cs tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreTombstoneTests.cs
|
||||
git commit -m "perf: replace FileStore full scans with indexed loops"
|
||||
```
|
||||
|
||||
## Task 5: Add benchmark coverage, update docs, and run full verification
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Benchmark.Tests/JetStream/FileStoreAppendBenchmarks.cs`
|
||||
- Modify: `Documentation/JetStream/Overview.md`
|
||||
|
||||
**Step 1: Add FileStore-focused benchmarks**
|
||||
- Cover:
|
||||
- append throughput
|
||||
- sync publish
|
||||
- load-last-by-subject
|
||||
- purge/trim maintenance overhead
|
||||
- Record allocation deltas before/after.
|
||||
|
||||
**Step 2: Update JetStream documentation**
|
||||
- Document how FileStore now separates metadata/index concerns from payload storage and where copies still remain by design.
|
||||
|
||||
**Step 3: Run full verification**
|
||||
- Run: `dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj --filter "FullyQualifiedName~FileStore|FullyQualifiedName~SyncPublish|FullyQualifiedName~AsyncPublish" -c Release`
|
||||
- Expected: PASS; benchmark output shows fewer allocations in append-heavy scenarios.
|
||||
|
||||
**Step 4: Commit docs and benchmarks**
|
||||
- Run:
|
||||
```bash
|
||||
git add tests/NATS.Server.Benchmark.Tests/JetStream/FileStoreAppendBenchmarks.cs Documentation/JetStream/Overview.md
|
||||
git commit -m "docs: record FileStore payload and index strategy"
|
||||
```
|
||||
|
||||
## Task 6: Merge the verified worktree branch back into `main`
|
||||
|
||||
**Files:**
|
||||
- No source-file changes expected unless the merge surfaces conflicts that require a follow-up fix
|
||||
|
||||
**Step 1: Confirm the worktree branch is clean and fully verified**
|
||||
- Re-run the Task 5 verification commands in the worktree if anything changed after the final commit.
|
||||
- Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
- Expected: no uncommitted changes.
|
||||
|
||||
**Step 2: Update `main` before merging**
|
||||
- From the primary checkout, run:
|
||||
```bash
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
```
|
||||
- Expected: local `main` matches the latest remote state.
|
||||
|
||||
**Step 3: Merge the finished branch back to `main`**
|
||||
- Run:
|
||||
```bash
|
||||
git merge --ff-only codex/filestore-payload-index-optimization
|
||||
```
|
||||
- Expected: `main` fast-forwards to include the completed FileStore optimization commits.
|
||||
- If fast-forward is not possible, rebase `codex/filestore-payload-index-optimization` onto `main`, re-run verification, and then repeat this step.
|
||||
|
||||
**Step 4: Confirm `main` still passes after the merge**
|
||||
- Run:
|
||||
```bash
|
||||
dotnet test tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj -c Release
|
||||
```
|
||||
- Expected: PASS on `main`.
|
||||
|
||||
**Step 5: Remove the temporary worktree after merge confirmation**
|
||||
- Run:
|
||||
```bash
|
||||
git worktree remove .worktrees/filestore-payload-index-optimization
|
||||
git branch -d codex/filestore-payload-index-optimization
|
||||
```
|
||||
- Expected: the temporary checkout is removed and the topic branch is no longer needed locally.
|
||||
|
||||
## Task 7: Run the benchmark suite per the benchmark README and update the comparison document
|
||||
|
||||
**Files:**
|
||||
- Modify: `benchmarks_comparison.md`
|
||||
- Reference: `tests/NATS.Server.Benchmark.Tests/README.md`
|
||||
|
||||
**Step 1: Run the full benchmark suite with the README-prescribed command**
|
||||
- From `main` after Task 6 succeeds, run:
|
||||
```bash
|
||||
dotnet test tests/NATS.Server.Benchmark.Tests \
|
||||
--filter "Category=Benchmark" \
|
||||
-v normal \
|
||||
--logger "console;verbosity=detailed" 2>&1 | tee /tmp/bench-output.txt
|
||||
```
|
||||
- Expected: the benchmark suite completes and writes comparison blocks to `/tmp/bench-output.txt`.
|
||||
|
||||
**Step 2: Extract the benchmark results from the captured output**
|
||||
- Review the `Standard Output Messages` sections in `/tmp/bench-output.txt`.
|
||||
- Capture the updated values for:
|
||||
- core pub/sub throughput
|
||||
- request/reply latency
|
||||
- JetStream sync publish
|
||||
- JetStream async file publish
|
||||
- ordered consumer throughput
|
||||
- durable consumer fetch throughput
|
||||
|
||||
**Step 3: Update `benchmarks_comparison.md`**
|
||||
- Update:
|
||||
- the benchmark run date on the first line
|
||||
- environment details if they changed
|
||||
- all affected tables with the new msg/s, MB/s, ratio, and latency values
|
||||
- the Summary and Key Observations text if the new ratios materially change the assessment
|
||||
|
||||
**Step 4: Verify the comparison document changes are the only remaining edits**
|
||||
- Run:
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
- Expected: only `benchmarks_comparison.md` is modified at this point unless the benchmark run surfaced a legitimate follow-up issue to capture separately.
|
||||
|
||||
**Step 5: Commit the benchmark comparison refresh**
|
||||
- Run:
|
||||
```bash
|
||||
git add benchmarks_comparison.md
|
||||
git commit -m "docs: update benchmark comparison after FileStore optimization"
|
||||
```
|
||||
|
||||
## Completion Checklist
|
||||
- [ ] Implementation started from an isolated git worktree on `codex/filestore-payload-index-optimization`.
|
||||
- [ ] `AppendAsync` and `StoreMsg` avoid unnecessary duplicate payload ownership.
|
||||
- [ ] `LoadLastBySubjectAsync`, `TrimToMaxMessages`, and `PurgeEx` no longer rely on repeated LINQ full scans.
|
||||
- [ ] First/last/live-sequence bookkeeping is maintained incrementally.
|
||||
- [ ] JetStream storage, recovery, compression, encryption, and tombstone tests remain green.
|
||||
- [ ] FileStore-focused benchmark coverage exists in `tests/NATS.Server.Benchmark.Tests/JetStream/`.
|
||||
- [ ] `Documentation/JetStream/Overview.md` explains the updated storage/index model.
|
||||
- [ ] Verified work has been merged back into `main` and the temporary worktree has been removed.
|
||||
- [ ] Full benchmark suite has been run from `main` using the command in `tests/NATS.Server.Benchmark.Tests/README.md`.
|
||||
- [ ] `benchmarks_comparison.md` has been updated to reflect the new benchmark results.
|
||||
|
||||
## Concise Execution Checklist For The Current Codebase
|
||||
- [ ] Create `codex/filestore-payload-index-optimization` in `.worktrees/filestore-payload-index-optimization` and verify `tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj` passes before changes.
|
||||
- [ ] Add optimization-guard coverage in `tests/NATS.Server.JetStream.Tests/JetStreamStoreIndexTests.cs`, `tests/NATS.Server.JetStream.Tests/JetStream/Storage/StoreInterfaceTests.cs`, and new `tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStoreOptimizationGuardTests.cs`.
|
||||
- [ ] Rework the current FileStore hot paths in `src/NATS.Server/JetStream/Storage/FileStore.cs`: `AppendAsync`, `LoadLastBySubjectAsync`, `TrimToMaxMessages`, `StoreMsg`, and `PurgeEx`.
|
||||
- [ ] Introduce compact FileStore indexing metadata in new `src/NATS.Server/JetStream/Storage/StoredMessageIndex.cs` and adjust `src/NATS.Server/JetStream/Storage/StoredMessage.cs` accordingly.
|
||||
- [ ] Remove avoidable payload duplication in `src/NATS.Server/JetStream/Storage/FileStore.cs`, `src/NATS.Server/JetStream/Storage/MsgBlock.cs`, and `src/NATS.Server/JetStream/Storage/MessageRecord.cs`.
|
||||
- [ ] Keep JetStream storage parity green by re-running the existing storage-focused suites under `tests/NATS.Server.JetStream.Tests/JetStream/Storage/`, especially compression, crash recovery, tombstones, and store interface coverage.
|
||||
- [ ] Add FileStore benchmark coverage alongside the existing JetStream benchmark classes in `tests/NATS.Server.Benchmark.Tests/JetStream/`.
|
||||
- [ ] Update `Documentation/JetStream/Overview.md` to describe the new payload/index split and the remaining intentional copy boundaries.
|
||||
- [ ] Merge the verified topic branch back into `main`, re-run JetStream tests on `main`, then remove the temporary worktree.
|
||||
- [ ] Run the full benchmark suite exactly as documented in `tests/NATS.Server.Benchmark.Tests/README.md` and update `benchmarks_comparison.md` with the new measurements.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Parser Span Retention Implementation Plan
|
||||
|
||||
> **For Codex:** REQUIRED SUB-SKILLS: Use `using-git-worktrees` to create an isolated worktree before making changes, `executeplan` to implement this plan task-by-task, and `finishing-a-development-branch` to merge the verified work back to `main` when implementation is complete.
|
||||
|
||||
**Goal:** Reduce parser hot-path allocations by keeping protocol fields and payloads in byte-oriented views until a caller explicitly needs materialized `string` or copied `byte[]` values.
|
||||
|
||||
**Architecture:** Introduce a byte-first parser representation alongside the current `ParsedCommand` contract, then migrate `NatsClient` and adjacent hot paths to consume the new representation without changing wire behavior. Preserve compatibility through an adapter layer so functional parity stays stable while allocation-heavy paths move to spans, pooled buffers, and sequence slices.
|
||||
|
||||
**Tech Stack:** .NET 10, C#, `System.Buffers`, `System.IO.Pipelines`, `ReadOnlySequence<byte>`, `SequenceReader<byte>`, xUnit, existing benchmark test harness.
|
||||
|
||||
---
|
||||
|
||||
## Scope Anchors
|
||||
- Primary source: `src/NATS.Server/Protocol/NatsParser.cs`
|
||||
- Primary consumer: `src/NATS.Server/NatsClient.cs`
|
||||
- Existing parser tests: `tests/NATS.Server.Core.Tests/ParserTests.cs`
|
||||
- Existing snippet/parity tests: `tests/NATS.Server.Core.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs`
|
||||
- Documentation to update: `Documentation/Protocol/Parser.md`
|
||||
- Benchmark project: `tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj`
|
||||
- Benchmark run instructions: `tests/NATS.Server.Benchmark.Tests/README.md`
|
||||
- Benchmark comparison report: `benchmarks_comparison.md`
|
||||
|
||||
## Task 0: Create an isolated git worktree for the parser optimization work
|
||||
|
||||
**Files:**
|
||||
- Verify: `.worktrees/`
|
||||
- Modify if needed: `.gitignore`
|
||||
|
||||
**Step 1: Verify the preferred worktree directory is available and ignored**
|
||||
- Check that `.worktrees/` exists and is ignored by git before creating a project-local worktree.
|
||||
- If `.worktrees/` is not ignored, add it to `.gitignore`, then commit that repository hygiene fix before continuing.
|
||||
|
||||
**Step 2: Create the feature worktree on a `codex/` branch**
|
||||
- Run:
|
||||
```bash
|
||||
git worktree add .worktrees/codex-parser-span-retention -b codex/parser-span-retention
|
||||
cd .worktrees/codex-parser-span-retention
|
||||
```
|
||||
- Expected: a new isolated worktree exists at `.worktrees/codex-parser-span-retention` on branch `codex/parser-span-retention`.
|
||||
|
||||
**Step 3: Verify the worktree starts from a clean, passing baseline**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj -c Release`
|
||||
- Expected: PASS. If this fails, stop and resolve or explicitly confirm whether to proceed from a failing baseline.
|
||||
|
||||
**Step 4: Commit any required worktree setup fix**
|
||||
- Only if `.gitignore` changed, run:
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: ignore local worktree directories"
|
||||
```
|
||||
|
||||
## Task 1: Freeze parser behavior and add allocation-focused tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/NATS.Server.Core.Tests/ParserTests.cs`
|
||||
- Modify: `tests/NATS.Server.Core.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs`
|
||||
- Create: `tests/NATS.Server.Core.Tests/Protocol/ParserSpanRetentionTests.cs`
|
||||
|
||||
**Step 1: Add failing tests for byte-first parser behavior**
|
||||
- Cover `PUB`, `HPUB`, `CONNECT`, and `INFO` with assertions that the new parser path can expose field data without forcing immediate `string` materialization.
|
||||
- Add split-payload cases to prove the parser preserves pending payload state across reads.
|
||||
|
||||
**Step 2: Add compatibility tests for existing `ParsedCommand` behavior**
|
||||
- Keep current semantics for `Type`, `Subject`, `ReplyTo`, `Queue`, `Sid`, `HeaderSize`, and `Payload`.
|
||||
- Ensure malformed protocol inputs still throw `ProtocolViolationException` with existing snippets/messages.
|
||||
|
||||
**Step 3: Run targeted tests to verify the new tests fail first**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~ParserTests|FullyQualifiedName~ParserSpanRetentionTests|FullyQualifiedName~ProtocolParserSnippetGapParityTests" -c Release`
|
||||
- Expected: FAIL in the newly added parser span-retention tests only.
|
||||
|
||||
**Step 4: Commit the failing-test baseline**
|
||||
- Run:
|
||||
```bash
|
||||
git add tests/NATS.Server.Core.Tests/ParserTests.cs tests/NATS.Server.Core.Tests/Protocol/ProtocolParserSnippetGapParityTests.cs tests/NATS.Server.Core.Tests/Protocol/ParserSpanRetentionTests.cs
|
||||
git commit -m "test: lock parser span-retention behavior"
|
||||
```
|
||||
|
||||
## Task 2: Introduce byte-oriented parser view types
|
||||
|
||||
**Files:**
|
||||
- Create: `src/NATS.Server/Protocol/ParsedCommandView.cs`
|
||||
- Modify: `src/NATS.Server/Protocol/NatsParser.cs`
|
||||
|
||||
**Step 1: Add a hot-path parser view contract**
|
||||
- Create a `ref struct` or small `readonly struct` representation for command views that can carry:
|
||||
- operation kind
|
||||
- subject/reply/queue/SID as spans or sequence-backed views
|
||||
- payload as `ReadOnlySequence<byte>` or `ReadOnlyMemory<byte>` when contiguous
|
||||
- header size and max-messages metadata
|
||||
|
||||
**Step 2: Add an adapter to the current `ParsedCommand` shape**
|
||||
- Keep the public/internal `ParsedCommand` entry point usable for existing consumers and tests.
|
||||
- Centralize materialization so `Encoding.ASCII.GetString(...)` and `ToArray()` happen in one adapter layer instead of inside every parse branch.
|
||||
|
||||
**Step 3: Re-run parser tests**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~Parser" -c Release`
|
||||
- Expected: FAIL only in branches not yet migrated to the new view path.
|
||||
|
||||
**Step 4: Commit the parser-view scaffolding**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/Protocol/ParsedCommandView.cs src/NATS.Server/Protocol/NatsParser.cs
|
||||
git commit -m "feat: add byte-oriented parser view contract"
|
||||
```
|
||||
|
||||
## Task 3: Rework control-line parsing and pending payload state
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/NATS.Server/Protocol/NatsParser.cs`
|
||||
|
||||
**Step 1: Remove early string materialization from control-line parsing**
|
||||
- Change `ParsePub`, `ParseHPub`, `ParseSub`, `ParseUnsub`, `ParseConnect`, and `ParseInfo` to keep raw byte slices in the hot parser path.
|
||||
- Replace `_pendingSubject` and `_pendingReplyTo` string fields with byte-oriented pending state.
|
||||
|
||||
**Step 2: Avoid unconditional payload copies**
|
||||
- Update `TryReadPayload()` so single-segment payloads can flow through as borrowed memory/slices.
|
||||
- Copy only when the payload is multi-segment or when the compatibility adapter explicitly requires a standalone buffer.
|
||||
|
||||
**Step 3: Replace repeated tiny literal allocations**
|
||||
- Stop using per-call `u8.ToArray()`-style buffers for CRLF and other fixed protocol tokens inside this parser path.
|
||||
- Add shared static buffers where appropriate.
|
||||
|
||||
**Step 4: Run targeted regression tests**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~ParserTests|FullyQualifiedName~ProtocolParserSnippetGapParityTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 5: Commit the parser hot-path rewrite**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/Protocol/NatsParser.cs
|
||||
git commit -m "perf: keep parser state in bytes until materialization"
|
||||
```
|
||||
|
||||
## Task 4: Migrate `NatsClient` to the new parser path without changing behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/NATS.Server/NatsClient.cs`
|
||||
- Modify: `tests/NATS.Server.Core.Tests/ParserTests.cs`
|
||||
- Modify: `tests/NATS.Server.Core.Tests/Protocol/ClientProtocolGoParityTests.cs`
|
||||
|
||||
**Step 1: Consume parser views first, materialize only at command handling boundaries**
|
||||
- Update `ProcessCommandsAsync` and any parser call sites so hot `PUB`/`HPUB` handling can read subject, reply, and payload from the byte-oriented representation.
|
||||
- Keep logging/tracing behavior intact, but ensure tracing is the only reason strings are created on trace-enabled paths.
|
||||
|
||||
**Step 2: Preserve feature parity**
|
||||
- Verify header parsing, payload size checks, connect/info handling, and slow-consumer/error behavior still match current tests.
|
||||
|
||||
**Step 3: Run consumer-facing protocol tests**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~ClientProtocolGoParityTests|FullyQualifiedName~ParserTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit the consumer migration**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Core.Tests/ParserTests.cs tests/NATS.Server.Core.Tests/Protocol/ClientProtocolGoParityTests.cs
|
||||
git commit -m "perf: consume parser command views in client hot path"
|
||||
```
|
||||
|
||||
## Task 5: Add benchmarks, document the change, run full verification, and refresh the benchmark comparison report
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Benchmark.Tests/Protocol/ParserHotPathBenchmarks.cs`
|
||||
- Modify: `Documentation/Protocol/Parser.md`
|
||||
- Modify: `benchmarks_comparison.md`
|
||||
|
||||
**Step 1: Add parser-focused benchmark coverage**
|
||||
- Add microbenchmarks for:
|
||||
- `PING` / `PONG`
|
||||
- `PUB`
|
||||
- `HPUB`
|
||||
- split payload reads
|
||||
- Capture throughput and allocation deltas before/after.
|
||||
|
||||
**Step 2: Update protocol documentation**
|
||||
- Document the new parser view + adapter split, why strings are deferred, and where payload copying is still intentionally required.
|
||||
|
||||
**Step 3: Run full verification**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj --filter "FullyQualifiedName~Parser" -c Release`
|
||||
- Expected: PASS; benchmark output shows reduced allocations relative to the baseline run.
|
||||
|
||||
**Step 4: Run the full benchmark suite per the benchmark project README**
|
||||
- Run:
|
||||
```bash
|
||||
dotnet test tests/NATS.Server.Benchmark.Tests \
|
||||
--filter "Category=Benchmark" \
|
||||
-v normal \
|
||||
--logger "console;verbosity=detailed" 2>&1 | tee /tmp/bench-output.txt
|
||||
```
|
||||
- Expected: benchmark comparison output is captured in `/tmp/bench-output.txt`, including the "Standard Output Messages" blocks described in `tests/NATS.Server.Benchmark.Tests/README.md`.
|
||||
|
||||
**Step 5: Update `benchmarks_comparison.md` with the new benchmark results**
|
||||
- Extract the comparison blocks from `/tmp/bench-output.txt`.
|
||||
- Update `benchmarks_comparison.md` with the latest msg/s, MB/s, ratio, and latency values.
|
||||
- Update the benchmark date, environment description, Summary table, and Key Observations so they match the new run.
|
||||
|
||||
**Step 6: Commit the verification, docs, and benchmark report refresh**
|
||||
- Run:
|
||||
```bash
|
||||
git add tests/NATS.Server.Benchmark.Tests/Protocol/ParserHotPathBenchmarks.cs Documentation/Protocol/Parser.md benchmarks_comparison.md
|
||||
git commit -m "docs: record parser hot-path allocation strategy"
|
||||
```
|
||||
|
||||
## Task 6: Merge the verified parser work back to `main` and clean up the worktree
|
||||
|
||||
**Files:**
|
||||
- No source changes expected
|
||||
|
||||
**Step 1: Confirm the feature branch is fully verified before merge**
|
||||
- Reuse the verification from Task 5. Do not merge if the core tests, parser benchmark tests, or full benchmark suite run did not complete successfully.
|
||||
|
||||
**Step 2: Merge `codex/parser-span-retention` back into `main`**
|
||||
- Return to the primary repository worktree and run:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
git merge codex/parser-span-retention
|
||||
```
|
||||
- Expected: the parser optimization commits merge cleanly into `main`.
|
||||
|
||||
**Step 3: Re-run verification on the merged `main` branch**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj --filter "FullyQualifiedName~Parser" -c Release`
|
||||
- Confirm `benchmarks_comparison.md` reflects the results captured from the Task 5 full benchmark suite.
|
||||
- Expected: PASS on merged `main`.
|
||||
|
||||
**Step 4: Delete the feature branch and remove the worktree**
|
||||
- Run:
|
||||
```bash
|
||||
git branch -d codex/parser-span-retention
|
||||
git worktree remove .worktrees/codex-parser-span-retention
|
||||
```
|
||||
- Expected: only `main` remains checked out in the primary workspace, and the temporary parser worktree is removed.
|
||||
|
||||
## Completion Checklist
|
||||
- [ ] Parser optimization work was implemented in an isolated `.worktrees/codex-parser-span-retention` worktree on branch `codex/parser-span-retention`.
|
||||
- [ ] `NatsParser` no longer materializes hot-path `string` values during parse unless the compatibility adapter requests them.
|
||||
- [ ] Single-segment payloads can pass through without an unconditional `byte[]` copy.
|
||||
- [ ] Existing parser and protocol behavior remains green in core tests.
|
||||
- [ ] Parser-focused benchmark coverage exists in `tests/NATS.Server.Benchmark.Tests/Protocol/`.
|
||||
- [ ] The full benchmark suite was run using the workflow from `tests/NATS.Server.Benchmark.Tests/README.md`.
|
||||
- [ ] `benchmarks_comparison.md` was updated to reflect the latest benchmark run.
|
||||
- [ ] `Documentation/Protocol/Parser.md` explains the byte-first parser architecture.
|
||||
- [ ] Verified parser optimization commits were merged back into `main`.
|
||||
@@ -0,0 +1,300 @@
|
||||
# SubList Allocation Reduction Implementation Plan
|
||||
|
||||
> **For Codex:** REQUIRED SUB-SKILLS: Use `using-git-worktrees` to create an isolated worktree before making changes, `executeplan` to implement this plan task-by-task, and `finishing-a-development-branch` to merge the verified work back to `main` when implementation is complete.
|
||||
|
||||
**Goal:** Reduce publish-path allocation and lookup overhead in `SubList` by removing composite string keys, minimizing `token.ToString()` churn, and tightening `Match()` result building without changing subscription semantics.
|
||||
|
||||
**Architecture:** First lock the current trie, cache, and remote-interest behavior with targeted tests. Then replace routed-sub bookkeeping with a dedicated value key, remove string split/rebuild work from remote cleanup, and finally optimize trie traversal and match result construction with span-friendly helpers and pooled builders.
|
||||
|
||||
**Tech Stack:** .NET 10, C#, `ReaderWriterLockSlim`, span-based token parsing, xUnit, existing clustering/gateway parity suites, benchmark test harness.
|
||||
|
||||
---
|
||||
|
||||
## Scope Anchors
|
||||
- Primary source: `src/NATS.Server/Subscriptions/SubList.cs`
|
||||
- Supporting source: `src/NATS.Server/Subscriptions/SubjectMatch.cs`
|
||||
- Existing core tests: `tests/NATS.Server.Core.Tests/Subscriptions/SubListGoParityTests.cs`
|
||||
- Existing ctor/notification tests: `tests/NATS.Server.Core.Tests/Subscriptions/SubListCtorAndNotificationParityTests.cs`
|
||||
- Route cleanup tests: `tests/NATS.Server.Clustering.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs`
|
||||
- Gateway/route interest tests:
|
||||
- `tests/NATS.Server.Gateways.Tests/Gateways/GatewayInterestModeTests.cs`
|
||||
- `tests/NATS.Server.Clustering.Tests/Routes/RouteInterestIdempotencyTests.cs`
|
||||
- `tests/NATS.Server.Clustering.Tests/Routes/RouteSubscriptionTests.cs`
|
||||
- Documentation to update: `Documentation/Subscriptions/SubList.md`
|
||||
- Benchmark workflow reference: `tests/NATS.Server.Benchmark.Tests/README.md`
|
||||
- Benchmark comparison document: `benchmarks_comparison.md`
|
||||
|
||||
## Task 0: Create an isolated git worktree for the SubList optimization work
|
||||
|
||||
**Files:**
|
||||
- Verify: `.worktrees/`
|
||||
- Modify if needed: `.gitignore`
|
||||
|
||||
**Step 1: Verify the preferred worktree directory is available and ignored**
|
||||
- Check that `.worktrees/` exists and is ignored by git before creating a project-local worktree.
|
||||
- If `.worktrees/` is not ignored, add it to `.gitignore`, then commit that repository hygiene fix before continuing.
|
||||
|
||||
**Step 2: Create the feature worktree on a `codex/` branch**
|
||||
- Run:
|
||||
```bash
|
||||
git worktree add .worktrees/codex-sublist-allocation-reduction -b codex/sublist-allocation-reduction
|
||||
cd .worktrees/codex-sublist-allocation-reduction
|
||||
```
|
||||
- Expected: a new isolated worktree exists at `.worktrees/codex-sublist-allocation-reduction` on branch `codex/sublist-allocation-reduction`.
|
||||
|
||||
**Step 3: Verify the worktree starts from a clean, passing baseline**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~SubList" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj --filter "FullyQualifiedName~RouteRemoteSubCleanupParityBatch2Tests|FullyQualifiedName~RouteInterestIdempotencyTests|FullyQualifiedName~RouteSubscriptionTests" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj --filter "FullyQualifiedName~GatewayInterestModeTests|FullyQualifiedName~GatewayInterestIdempotencyTests|FullyQualifiedName~GatewayForwardingTests" -c Release`
|
||||
- Expected: PASS. If this fails, stop and resolve or explicitly confirm whether to proceed from a failing baseline.
|
||||
|
||||
**Step 4: Commit any required worktree setup fix**
|
||||
- Only if `.gitignore` changed, run:
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: ignore local worktree directories"
|
||||
```
|
||||
|
||||
## Task 1: Lock behavior around remote interest, cleanup, and matching
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/NATS.Server.Core.Tests/Subscriptions/SubListGoParityTests.cs`
|
||||
- Modify: `tests/NATS.Server.Clustering.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs`
|
||||
- Create: `tests/NATS.Server.Core.Tests/Subscriptions/SubListAllocationGuardTests.cs`
|
||||
|
||||
**Step 1: Add failing tests for routed-sub bookkeeping changes**
|
||||
- Cover:
|
||||
- applying the same remote subscription twice
|
||||
- removing remote subscriptions by route and by route/account
|
||||
- queue-weight updates
|
||||
- exact and wildcard remote-interest queries
|
||||
|
||||
**Step 2: Add tests for match result stability**
|
||||
- Ensure `Match()` still returns correct plain and queue subscription sets for exact, `*`, and `>` subjects.
|
||||
- Add a test that specifically locks cache behavior across generation bumps.
|
||||
|
||||
**Step 3: Run the focused tests to prove the new coverage fails first**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~SubList" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj --filter "FullyQualifiedName~RouteRemoteSubCleanupParityBatch2Tests|FullyQualifiedName~RouteInterestIdempotencyTests" -c Release`
|
||||
- Expected: FAIL only in the newly added allocation-guard or key-behavior tests.
|
||||
|
||||
**Step 4: Commit the failing-test baseline**
|
||||
- Run:
|
||||
```bash
|
||||
git add tests/NATS.Server.Core.Tests/Subscriptions/SubListGoParityTests.cs tests/NATS.Server.Core.Tests/Subscriptions/SubListAllocationGuardTests.cs tests/NATS.Server.Clustering.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs
|
||||
git commit -m "test: lock SubList remote-key and match behavior"
|
||||
```
|
||||
|
||||
## Task 2: Replace composite routed-sub strings with a dedicated value key
|
||||
|
||||
**Files:**
|
||||
- Create: `src/NATS.Server/Subscriptions/RoutedSubKey.cs`
|
||||
- Modify: `src/NATS.Server/Subscriptions/SubList.cs`
|
||||
- Modify: `tests/NATS.Server.Clustering.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs`
|
||||
|
||||
**Step 1: Introduce a strongly typed routed-sub key**
|
||||
- Add a small immutable value type for `(RouteId, Account, Subject, Queue)`.
|
||||
- Use it as the dictionary key for `_remoteSubs` instead of the `"route|account|subject|queue"` composite string.
|
||||
|
||||
**Step 2: Remove string split/rebuild helpers from hot paths**
|
||||
- Replace `BuildRoutedSubKey(...)`, `GetAccNameFromRoutedSubKey(...)`, and `GetRoutedSubKeyInfo(...)` usage in runtime paths with the new value key.
|
||||
- Keep compatibility helper coverage only if other call sites still require string-facing helpers temporarily.
|
||||
|
||||
**Step 3: Run remote-interest tests**
|
||||
- Run: `dotnet test tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj --filter "FullyQualifiedName~RouteRemoteSubCleanupParityBatch2Tests|FullyQualifiedName~RouteSubscriptionTests" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj --filter "FullyQualifiedName~GatewayInterestModeTests|FullyQualifiedName~GatewayInterestIdempotencyTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit the key-model refactor**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/Subscriptions/RoutedSubKey.cs src/NATS.Server/Subscriptions/SubList.cs tests/NATS.Server.Clustering.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs
|
||||
git commit -m "perf: replace SubList routed-sub string keys"
|
||||
```
|
||||
|
||||
## Task 3: Remove avoidable string churn from trie traversal
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/NATS.Server/Subscriptions/SubList.cs`
|
||||
- Modify: `src/NATS.Server/Subscriptions/SubjectMatch.cs`
|
||||
- Modify: `tests/NATS.Server.Core.Tests/Subscriptions/SubListGoParityTests.cs`
|
||||
|
||||
**Step 1: Rework token traversal helpers**
|
||||
- Add a shared subject token walker that can expose tokens as spans and only allocate when a trie node insertion truly needs a durable string key.
|
||||
- Remove repeated `token.ToString()` in traversal paths where lookups can operate on a transient token view first.
|
||||
|
||||
**Step 2: Keep exact-subject match paths allocation-lean**
|
||||
- Prefer span/token comparison helpers for exact-match and wildcard traversal logic.
|
||||
- Leave wildcard semantics unchanged.
|
||||
|
||||
**Step 3: Run core subscription tests**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~SubListGoParityTests|FullyQualifiedName~SubListCtorAndNotificationParityTests|FullyQualifiedName~SubListParityBatch2Tests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit trie traversal cleanup**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/Subscriptions/SubList.cs src/NATS.Server/Subscriptions/SubjectMatch.cs tests/NATS.Server.Core.Tests/Subscriptions/SubListGoParityTests.cs
|
||||
git commit -m "perf: reduce SubList token string churn"
|
||||
```
|
||||
|
||||
## Task 4: Pool `Match()` result building and remove cleanup copies
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/NATS.Server/Subscriptions/SubList.cs`
|
||||
- Modify: `tests/NATS.Server.Core.Tests/Subscriptions/SubListAllocationGuardTests.cs`
|
||||
- Modify: `tests/NATS.Server.Clustering.Tests/Routes/RouteSubscriptionTests.cs`
|
||||
|
||||
**Step 1: Replace temporary per-match collections**
|
||||
- Rework `Match()` so temporary result building uses pooled builders or `ArrayBufferWriter<T>` instead of fresh nested `List<T>` allocations on every call.
|
||||
- Preserve the current public `SubListResult` shape unless profiling proves a larger contract change is justified.
|
||||
|
||||
**Step 2: Remove `ToArray()` cleanup passes over `_remoteSubs`**
|
||||
- Update `RemoveRemoteSubs(...)` and `RemoveRemoteSubsForAccount(...)` to avoid eager dictionary array copies.
|
||||
- Ensure removal remains correct under the existing lock discipline.
|
||||
|
||||
**Step 3: Run cross-module regression**
|
||||
- Run: `dotnet test tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj --filter "FullyQualifiedName~RouteSubscriptionTests|FullyQualifiedName~RouteInterestIdempotencyTests" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj --filter "FullyQualifiedName~GatewayInterestModeTests|FullyQualifiedName~GatewayForwardingTests" -c Release`
|
||||
- Expected: PASS.
|
||||
|
||||
**Step 4: Commit match-builder changes**
|
||||
- Run:
|
||||
```bash
|
||||
git add src/NATS.Server/Subscriptions/SubList.cs tests/NATS.Server.Core.Tests/Subscriptions/SubListAllocationGuardTests.cs tests/NATS.Server.Clustering.Tests/Routes/RouteSubscriptionTests.cs
|
||||
git commit -m "perf: pool SubList match builders and cleanup scans"
|
||||
```
|
||||
|
||||
## Task 5: Add benchmark coverage, update docs, and run full verification
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Benchmark.Tests/CorePubSub/SubListMatchBenchmarks.cs`
|
||||
- Modify: `Documentation/Subscriptions/SubList.md`
|
||||
|
||||
**Step 1: Add focused `SubList` benchmarks**
|
||||
- Measure exact-match, wildcard-match, queue-sub, and remote-interest scenarios.
|
||||
- Capture throughput and allocations before/after the refactor.
|
||||
|
||||
**Step 2: Update subscription documentation**
|
||||
- Document the new routed-sub key model, the allocation strategy for trie matching, and any remaining intentional copies.
|
||||
|
||||
**Step 3: Run full verification**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj --filter "FullyQualifiedName~SubList" -c Release`
|
||||
- Expected: PASS; benchmark output shows reduced per-match allocations.
|
||||
|
||||
**Step 4: Commit the documentation and benchmark work**
|
||||
- Run:
|
||||
```bash
|
||||
git add tests/NATS.Server.Benchmark.Tests/CorePubSub/SubListMatchBenchmarks.cs Documentation/Subscriptions/SubList.md
|
||||
git commit -m "docs: record SubList allocation strategy"
|
||||
```
|
||||
|
||||
## Task 6: Merge the verified SubList work back to `main` and clean up the worktree
|
||||
|
||||
**Files:**
|
||||
- No source changes expected
|
||||
|
||||
**Step 1: Confirm the feature branch is fully verified before merge**
|
||||
- Reuse the verification from Task 5. Do not merge if the core, clustering, gateway, or benchmark test commands are failing.
|
||||
|
||||
**Step 2: Merge `codex/sublist-allocation-reduction` back into `main`**
|
||||
- Return to the primary repository worktree and run:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
git merge codex/sublist-allocation-reduction
|
||||
```
|
||||
- Expected: the SubList optimization commits merge cleanly into `main`.
|
||||
|
||||
**Step 3: Re-run verification on the merged `main` branch**
|
||||
- Run: `dotnet test tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj --filter "FullyQualifiedName~SubList" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj --filter "FullyQualifiedName~RouteRemoteSubCleanupParityBatch2Tests|FullyQualifiedName~RouteInterestIdempotencyTests|FullyQualifiedName~RouteSubscriptionTests" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj --filter "FullyQualifiedName~GatewayInterestModeTests|FullyQualifiedName~GatewayInterestIdempotencyTests|FullyQualifiedName~GatewayForwardingTests" -c Release`
|
||||
- Run: `dotnet test tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj --filter "FullyQualifiedName~SubList" -c Release`
|
||||
- Expected: PASS on merged `main`.
|
||||
|
||||
**Step 4: Delete the feature branch and remove the worktree**
|
||||
- Run:
|
||||
```bash
|
||||
git branch -d codex/sublist-allocation-reduction
|
||||
git worktree remove .worktrees/codex-sublist-allocation-reduction
|
||||
```
|
||||
- Expected: only `main` remains checked out in the primary workspace, and the temporary SubList worktree is removed.
|
||||
|
||||
## Task 7: Run the full benchmark suite per the benchmark README and update `benchmarks_comparison.md`
|
||||
|
||||
**Files:**
|
||||
- Verify workflow against: `tests/NATS.Server.Benchmark.Tests/README.md`
|
||||
- Modify: `benchmarks_comparison.md`
|
||||
|
||||
**Step 1: Run the benchmark test project using the repository-documented command**
|
||||
- From the primary repository worktree on verified `main`, run:
|
||||
```bash
|
||||
dotnet test tests/NATS.Server.Benchmark.Tests \
|
||||
--filter "Category=Benchmark" \
|
||||
-v normal \
|
||||
--logger "console;verbosity=detailed" 2>&1 | tee /tmp/bench-output.txt
|
||||
```
|
||||
- Expected: the full benchmark suite completes and writes detailed comparison output to `/tmp/bench-output.txt`.
|
||||
|
||||
**Step 2: Extract the benchmark comparison blocks from the captured output**
|
||||
- Open `/tmp/bench-output.txt` and pull the side-by-side comparison blocks from the `Standard Output Messages` sections for:
|
||||
- core pub-only
|
||||
- core 1:1 pub/sub
|
||||
- core fan-out
|
||||
- core multi pub/sub
|
||||
- request/reply
|
||||
- JetStream publish
|
||||
- JetStream consumption
|
||||
|
||||
**Step 3: Update `benchmarks_comparison.md`**
|
||||
- Refresh:
|
||||
- the benchmark run date on the first line
|
||||
- the environment description if toolchain or machine details changed
|
||||
- throughput, MB/s, ratio, and latency values in the benchmark tables
|
||||
- summary assessments and key observations if the ratios materially changed
|
||||
- Keep the narrative tied to measured results from `/tmp/bench-output.txt`; do not preserve stale claims that no longer match the numbers.
|
||||
|
||||
**Step 4: Commit the benchmark comparison refresh**
|
||||
- Run:
|
||||
```bash
|
||||
git add benchmarks_comparison.md
|
||||
git commit -m "docs: refresh benchmark comparison after SubList optimization"
|
||||
```
|
||||
- Expected: `main` contains the merged SubList optimization work plus the refreshed benchmark comparison document.
|
||||
|
||||
## Completion Checklist
|
||||
- [ ] SubList optimization work was implemented in an isolated `.worktrees/codex-sublist-allocation-reduction` worktree on branch `codex/sublist-allocation-reduction`.
|
||||
- [ ] `_remoteSubs` no longer uses composite string keys in runtime paths.
|
||||
- [ ] Remote cleanup paths no longer depend on `Split('|')` and `_remoteSubs.ToArray()`.
|
||||
- [ ] Trie traversal materially reduces `token.ToString()` churn.
|
||||
- [ ] `Match()` uses pooled or allocation-lean temporary builders.
|
||||
- [ ] Core, clustering, and gateway parity tests remain green.
|
||||
- [ ] `Documentation/Subscriptions/SubList.md` explains the new key and match strategy.
|
||||
- [ ] Verified SubList optimization commits were merged back into `main`.
|
||||
- [ ] The full benchmark test project was run on merged `main` per `tests/NATS.Server.Benchmark.Tests/README.md`.
|
||||
- [ ] `benchmarks_comparison.md` was updated to match the latest benchmark output.
|
||||
|
||||
## Concise Execution Checklist (Current Codebase)
|
||||
- [ ] Start from the current repo root and leave unrelated MQTT worktree changes untouched.
|
||||
- [ ] Create `.worktrees/codex-sublist-allocation-reduction` on `codex/sublist-allocation-reduction`.
|
||||
- [ ] Verify the current SubList baseline with:
|
||||
- `tests/NATS.Server.Core.Tests/Subscriptions/SubListGoParityTests.cs`
|
||||
- `tests/NATS.Server.Core.Tests/Subscriptions/SubListCtorAndNotificationParityTests.cs`
|
||||
- `tests/NATS.Server.Core.Tests/Subscriptions/SubListParityBatch2Tests.cs`
|
||||
- `tests/NATS.Server.Clustering.Tests/Routes/RouteRemoteSubCleanupParityBatch2Tests.cs`
|
||||
- `tests/NATS.Server.Clustering.Tests/Routes/RouteInterestIdempotencyTests.cs`
|
||||
- `tests/NATS.Server.Clustering.Tests/Routes/RouteSubscriptionTests.cs`
|
||||
- `tests/NATS.Server.Gateways.Tests/Gateways/GatewayInterestModeTests.cs`
|
||||
- `tests/NATS.Server.Gateways.Tests/Gateways/GatewayForwardingTests.cs`
|
||||
- [ ] Add new guard coverage in `tests/NATS.Server.Core.Tests/Subscriptions/SubListAllocationGuardTests.cs`.
|
||||
- [ ] Refactor `src/NATS.Server/Subscriptions/SubList.cs` to use a new `src/NATS.Server/Subscriptions/RoutedSubKey.cs`.
|
||||
- [ ] Reduce token string churn in `src/NATS.Server/Subscriptions/SubList.cs` and `src/NATS.Server/Subscriptions/SubjectMatch.cs`.
|
||||
- [ ] Add a new benchmark file at `tests/NATS.Server.Benchmark.Tests/CorePubSub/SubListMatchBenchmarks.cs` beside the existing CorePubSub benchmarks.
|
||||
- [ ] Update `Documentation/Subscriptions/SubList.md`.
|
||||
- [ ] Run full verification for core, clustering, gateway, and focused SubList benchmark tests.
|
||||
- [ ] Merge `codex/sublist-allocation-reduction` into `main` and re-run the merged verification.
|
||||
- [ ] Run the full benchmark suite using `tests/NATS.Server.Benchmark.Tests/README.md` guidance and refresh `benchmarks_comparison.md`.
|
||||
@@ -0,0 +1,75 @@
|
||||
# dotTrace DTP Parser Design
|
||||
|
||||
**Goal:** Build a repository-local tool that starts from a raw dotTrace `.dtp` snapshot family and emits machine-readable JSON call-tree data suitable for LLM-driven hotspot analysis.
|
||||
|
||||
**Context**
|
||||
|
||||
The target snapshot format is JetBrains dotTrace multi-file storage:
|
||||
|
||||
- `snapshot.dtp` is the index/manifest.
|
||||
- `snapshot.dtp.0000`, `.0001`, and related files hold the storage sections.
|
||||
- `snapshot.dtp.States` holds UI state and is not sufficient for call-tree analysis.
|
||||
|
||||
The internal binary layout is not publicly specified. A direct handwritten decoder would be brittle and expensive to maintain. The machine already has dotTrace installed, and the shipped JetBrains assemblies expose snapshot storage, metadata, and performance call-tree readers. The design therefore uses dotTrace’s local runtime libraries as the authoritative decoder while still starting from the raw `.dtp` files.
|
||||
|
||||
**Architecture**
|
||||
|
||||
Two layers:
|
||||
|
||||
1. A small .NET helper opens the raw snapshot, reads the performance DFS call-tree and node payload sections, resolves function names through the profiler metadata section, and emits JSON.
|
||||
2. A Python CLI is the user-facing entrypoint. It validates input, builds or reuses the helper, runs it, and writes JSON to stdout or a file.
|
||||
|
||||
This keeps the user workflow Python-first while using the only reliable decoder available for the undocumented snapshot format.
|
||||
|
||||
**Output schema**
|
||||
|
||||
The JSON should support both direct consumption and downstream summarization:
|
||||
|
||||
- `snapshot`: source path, thread count, node count, payload type.
|
||||
- `thread_roots`: thread root metadata.
|
||||
- `call_tree`: synthetic root with recursive children.
|
||||
- `hotspots`: flat top lists for inclusive and exclusive time.
|
||||
|
||||
Each node should include:
|
||||
|
||||
- `id`: stable offset-based identifier.
|
||||
- `name`: resolved method or synthetic node name.
|
||||
- `kind`: `root`, `thread`, `method`, or `special`.
|
||||
- `inclusive_time`
|
||||
- `exclusive_time`
|
||||
- `call_count`
|
||||
- `thread_name` when relevant
|
||||
- `children`
|
||||
|
||||
**Resolution strategy**
|
||||
|
||||
Method names are resolved from the snapshot’s metadata section:
|
||||
|
||||
- Use the snapshot’s FUID-to-metadata converter.
|
||||
- Map `FunctionUID` to `FunctionId`.
|
||||
- Resolve `MetadataId`.
|
||||
- Read function and class data with `MetadataSectionHelpers`.
|
||||
|
||||
Synthetic and special frames fall back to explicit labels instead of opaque numeric values where possible.
|
||||
|
||||
**Error handling**
|
||||
|
||||
The tool should fail loudly for the cases that matter:
|
||||
|
||||
- Missing dotTrace assemblies.
|
||||
- Unsupported snapshot layout.
|
||||
- Missing metadata sections.
|
||||
- Helper build or execution failure.
|
||||
|
||||
Errors should name the failing stage so the Python wrapper can surface actionable messages.
|
||||
|
||||
**Testing**
|
||||
|
||||
Use the checked-in sample snapshot at `snapshots/js-ordered-consume.dtp` for an end-to-end test:
|
||||
|
||||
- JSON parses successfully.
|
||||
- The root contains thread children.
|
||||
- Hotspot lists are populated.
|
||||
- At least one non-special method name is resolved.
|
||||
|
||||
This is enough to verify the extraction path without freezing the entire output.
|
||||
@@ -0,0 +1,186 @@
|
||||
# dotTrace DTP Parser Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a Python-first tool that reads a raw dotTrace `.dtp` snapshot family and emits JSON call-tree and hotspot data for LLM analysis.
|
||||
|
||||
**Architecture:** A small .NET helper uses JetBrains’ local dotTrace assemblies to decode snapshot storage, performance call-tree nodes, payloads, and metadata. A Python wrapper validates input, builds the helper if needed, runs it, and writes the resulting JSON.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, .NET 10 console app, local JetBrains dotTrace assemblies, `unittest`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the failing end-to-end test
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/tests/test_dtp_parser.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Write a `unittest` test that runs:
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --stdout
|
||||
```
|
||||
|
||||
and asserts:
|
||||
|
||||
- exit code is `0`
|
||||
- stdout is valid JSON
|
||||
- `call_tree.children` is non-empty
|
||||
- `hotspots.inclusive` is non-empty
|
||||
- at least one node name is not marked as special
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python3 -m unittest tools.tests.test_dtp_parser -v`
|
||||
|
||||
Expected: FAIL because `tools/dtp_parse.py` does not exist yet.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/tests/test_dtp_parser.py
|
||||
git commit -m "test: add dtp parser end-to-end expectation"
|
||||
```
|
||||
|
||||
### Task 2: Implement the .NET snapshot extractor
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/DtpSnapshotExtractor/DtpSnapshotExtractor.csproj`
|
||||
- Create: `tools/DtpSnapshotExtractor/Program.cs`
|
||||
|
||||
**Step 1: Write the minimal implementation**
|
||||
|
||||
Implement a console app that:
|
||||
|
||||
- accepts snapshot path and optional output path
|
||||
- opens the snapshot through JetBrains snapshot storage
|
||||
- constructs performance call-tree and payload readers
|
||||
- resolves method names via metadata sections
|
||||
- builds a JSON object with root tree, thread roots, and hotspot lists
|
||||
- writes JSON to stdout or output file
|
||||
|
||||
**Step 2: Run helper directly**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/DtpSnapshotExtractor -- snapshots/js-ordered-consume.dtp
|
||||
```
|
||||
|
||||
Expected: JSON is emitted successfully.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/DtpSnapshotExtractor/DtpSnapshotExtractor.csproj tools/DtpSnapshotExtractor/Program.cs
|
||||
git commit -m "feat: add dottrace snapshot extractor helper"
|
||||
```
|
||||
|
||||
### Task 3: Implement the Python entrypoint
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/dtp_parse.py`
|
||||
|
||||
**Step 1: Write the minimal implementation**
|
||||
|
||||
Implement a CLI that:
|
||||
|
||||
- accepts snapshot path
|
||||
- supports `--out` and `--stdout`
|
||||
- checks that dotTrace assemblies exist in the local install
|
||||
- runs `dotnet run --project tools/DtpSnapshotExtractor -- <snapshot>`
|
||||
- forwards JSON output
|
||||
|
||||
**Step 2: Run the wrapper**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --stdout
|
||||
```
|
||||
|
||||
Expected: JSON is emitted successfully.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/dtp_parse.py
|
||||
git commit -m "feat: add python dtp parsing entrypoint"
|
||||
```
|
||||
|
||||
### Task 4: Make the test pass and tighten output
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/DtpSnapshotExtractor/Program.cs`
|
||||
- Modify: `tools/dtp_parse.py`
|
||||
- Modify: `tools/tests/test_dtp_parser.py`
|
||||
|
||||
**Step 1: Run the failing test**
|
||||
|
||||
Run: `python3 -m unittest tools.tests.test_dtp_parser -v`
|
||||
|
||||
Expected: FAIL with an output-schema or execution issue.
|
||||
|
||||
**Step 2: Fix the minimal failing behavior**
|
||||
|
||||
Adjust:
|
||||
|
||||
- special-node labeling
|
||||
- JSON schema stability
|
||||
- helper invocation details
|
||||
- fallback behavior for unresolved metadata
|
||||
|
||||
**Step 3: Re-run the test**
|
||||
|
||||
Run: `python3 -m unittest tools.tests.test_dtp_parser -v`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/DtpSnapshotExtractor/Program.cs tools/dtp_parse.py tools/tests/test_dtp_parser.py
|
||||
git commit -m "test: verify dtp parser output"
|
||||
```
|
||||
|
||||
### Task 5: Final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: none unless fixes are required
|
||||
|
||||
**Step 1: Run end-to-end extraction**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --out /tmp/js-ordered-consume-calltree.json
|
||||
```
|
||||
|
||||
Expected: JSON file is created.
|
||||
|
||||
**Step 2: Run test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tools.tests.test_dtp_parser -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Inspect a hotspot sample**
|
||||
|
||||
Confirm the JSON contains:
|
||||
|
||||
- resolved method names
|
||||
- inclusive and exclusive hotspot lists
|
||||
- nested thread call trees
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-03-14-dtp-parser-design.md docs/plans/2026-03-14-dtp-parser.md
|
||||
git commit -m "docs: add dtp parser design and plan"
|
||||
```
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Parsing Raw `.dtp` Snapshots To JSON
|
||||
|
||||
The repository includes a Python-first parser for raw dotTrace sampling and tracing snapshots:
|
||||
|
||||
- Python entrypoint: [tools/dtp_parse.py](/Users/dohertj2/Desktop/natsdotnet/tools/dtp_parse.py)
|
||||
- .NET helper: [tools/DtpSnapshotExtractor/Program.cs](/Users/dohertj2/Desktop/natsdotnet/tools/DtpSnapshotExtractor/Program.cs)
|
||||
|
||||
The parser starts from the raw `.dtp` snapshot family and emits machine-readable JSON for call-tree and hotspot analysis. It uses the locally installed dotTrace assemblies to decode the snapshot format.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- `python3`
|
||||
- `.NET 10 SDK`
|
||||
- dotTrace installed at `/Users/dohertj2/Applications/dotTrace.app`
|
||||
|
||||
If dotTrace is installed elsewhere, set `DOTTRACE_APP_DIR` to the `Contents/DotFiles` directory:
|
||||
|
||||
```bash
|
||||
export DOTTRACE_APP_DIR="/path/to/dotTrace.app/Contents/DotFiles"
|
||||
```
|
||||
|
||||
### Print JSON to stdout
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --stdout
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--top N` limits hotspot and flat-path output. Default: `200`
|
||||
- `--filter TEXT` keeps only call-tree paths and hotspots whose method names match `TEXT`
|
||||
- `--flat` or `--paths` adds a `hotPaths` section with the heaviest flat call chains
|
||||
- `--include-idle` keeps idle and wait methods in hotspot/path rankings. Idle exclusion is on by default.
|
||||
|
||||
### Write JSON to a file
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp \
|
||||
--out /tmp/js-ordered-consume-calltree.json
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp \
|
||||
--filter Microsoft.DotNet.Cli.Program \
|
||||
--flat \
|
||||
--top 25 \
|
||||
--out /tmp/js-ordered-consume-calltree.json
|
||||
```
|
||||
|
||||
### Output shape
|
||||
|
||||
The generated JSON contains:
|
||||
|
||||
- `snapshot` — source path, payload type, time unit, thread count, node count, and reader diagnostics
|
||||
- `summary` — wall time, active time, total samples, and top exclusive method summary
|
||||
- `threadRoots` — top-level thread roots with inclusive time
|
||||
- `callTree` — nested call tree rooted at a synthetic `<root>`
|
||||
- `hotspots` — flat `inclusive` and `exclusive` method lists
|
||||
- `hotPaths` — optional flat call-path list when `--flat` is used
|
||||
|
||||
Hotspot entries are method-first. Synthetic frames such as thread roots are excluded from hotspot rankings, and idle wait frames are excluded by default so the output is easier to feed into an LLM for slowdown analysis.
|
||||
|
||||
### Typical analysis workflow
|
||||
|
||||
1. Capture a snapshot with `dottrace`.
|
||||
2. Convert the raw `.dtp` snapshot to JSON:
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/nats-sampling.dtp \
|
||||
--out /tmp/nats-sampling-calltree.json
|
||||
```
|
||||
|
||||
3. Inspect the top hotspots:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
with open('/tmp/nats-sampling-calltree.json') as f:
|
||||
data = json.load(f)
|
||||
print('Top inclusive:', data['hotspots']['inclusive'][0]['name'])
|
||||
print('Top exclusive:', data['hotspots']['exclusive'][0]['name'])
|
||||
PY
|
||||
```
|
||||
|
||||
4. Feed the JSON into downstream tooling or an LLM to walk the call tree and identify expensive paths.
|
||||
|
||||
### Verification
|
||||
|
||||
Run the parser test with:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tools.tests.test_dtp_parser -v
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
- The parser currently targets raw `.dtp` snapshots. Timeline `.dtt` snapshots are still intended for the GUI viewer.
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# DTP Snapshot Extractor — Requested Changes
|
||||
|
||||
## Problem
|
||||
|
||||
The current extractor produces too few nodes to be useful for performance analysis. A 30-second dotTrace sampling snapshot of the NATS server handling 1M messages (5s publish + 1.3s consume) yields only **202 nodes** in the JSON output. The entire publish/consume hot path is invisible — no `ProcessCommandsAsync`, `ProcessMessage`, `DeliverPullFetchMessagesAsync`, `SendMessageNoFlush`, `SubList.Match`, `FileStore.AppendAsync`, `MsgBlock.WriteAt`, or any other NATS server method appears in the call tree. Only server startup/shutdown and `InternalEventSystem` event serialization show up.
|
||||
|
||||
By contrast, the dotTrace GUI shows thousands of samples across these functions with clear call trees and accurate timing. The `.dtp` file has the data — the extractor is not surfacing it.
|
||||
|
||||
### Evidence
|
||||
|
||||
```
|
||||
Snapshot: threads=11, nodes=202
|
||||
Top NATS inclusive hotspots:
|
||||
NatsServer.WaitForShutdown 29741.8ms (idle wait)
|
||||
NatsServer..ctor 36.5ms (one-time init)
|
||||
InternalEventSystem 26.0ms (periodic stats)
|
||||
```
|
||||
|
||||
The actual hot path (`ProcessCommandsAsync` → `ProcessMessage` → fan-out/delivery) which runs for ~6 seconds of wall time is completely absent.
|
||||
|
||||
---
|
||||
|
||||
## Change 1: Increase Hotspot Limit
|
||||
|
||||
**Current:** `Take(50)` in `BuildHotspots` for both inclusive and exclusive lists.
|
||||
|
||||
**Requested:** Increase to at least **200**, or make it configurable via a CLI flag (e.g., `--top N`). With only 50 hotspots, important functions lower in the ranking are silently dropped.
|
||||
|
||||
---
|
||||
|
||||
## Change 2: Add `--filter` Flag to Python CLI
|
||||
|
||||
Add a `--filter` option that passes a substring filter to the .NET helper, so the JSON output only includes nodes whose name matches the filter. This reduces noise and lets me focus on the relevant code:
|
||||
|
||||
```bash
|
||||
python3 tools/dtp_parse.py snapshots/foo.dtp --filter NATS --out /tmp/result.json
|
||||
```
|
||||
|
||||
The .NET helper should filter the hotspot lists and prune the call tree to only include paths that contain at least one matching node (keeping ancestors and descendants of matching nodes).
|
||||
|
||||
---
|
||||
|
||||
## Change 3: Add Flat Call-Path Output Mode
|
||||
|
||||
The current nested call tree is hard to consume programmatically for hot-path analysis. Add a `--flat` or `--paths` mode that outputs the **top N heaviest call paths** as flat strings with timing:
|
||||
|
||||
```json
|
||||
{
|
||||
"hotPaths": [
|
||||
{
|
||||
"path": "ThreadPool > ProcessCommandsAsync > ProcessMessage > DeliverPullFetchMessagesAsync > SendMessageNoFlush",
|
||||
"inclusiveMs": 342.5,
|
||||
"leafExclusiveMs": 89.2
|
||||
},
|
||||
{
|
||||
"path": "ThreadPool > ProcessCommandsAsync > ProcessMessage > SubList.Match",
|
||||
"inclusiveMs": 156.3,
|
||||
"leafExclusiveMs": 156.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is the most useful output format for LLM-driven analysis — I can immediately see which call chains are expensive without walking the tree.
|
||||
|
||||
---
|
||||
|
||||
## Change 4: Exclude Idle/Wait Functions from Hotspots
|
||||
|
||||
Functions like `WaitHandle.WaitOneNoCheck`, `SemaphoreSlim.WaitCore`, `LowLevelLifoSemaphore.WaitForSignal`, `Monitor.Wait`, `SocketAsyncEngine.EventLoop`, `Thread.PollGC`, and `Interop+Sys.WaitForSocketEvents` dominate the hotspot lists but represent idle waiting, not actual CPU work. Either:
|
||||
|
||||
- Add a `--exclude-idle` flag (default on) that strips these from hotspot lists, or
|
||||
- Always exclude them from the `exclusive` hotspot list (they have zero useful exclusive time) and keep them in `inclusive` only if requested.
|
||||
|
||||
---
|
||||
|
||||
## Change 5: Investigate Missing Nodes (Critical)
|
||||
|
||||
This is the most important issue. **202 nodes from a 30-second sampling profile is far too few.** The dotTrace GUI shows the same snapshot with a full, deep call tree across all ThreadPool workers. Possible causes:
|
||||
|
||||
1. **The DFS reader is not reading all sections.** The `callTreeSections.AllHeaders()` call may not be returning headers for all threads or all sampling intervals. Check whether there are multiple call tree section families and the current code only reads one.
|
||||
|
||||
2. **Node merging/deduplication is losing data.** If two threads call the same function, they may share a `FunctionUID` but have different `CallTreeSectionOffset` values. Verify that the `nodeMap` dictionary keyed by offset isn't accidentally losing nodes from different threads.
|
||||
|
||||
3. **The `totalNodeCount` calculation may be wrong.** The formula `(SectionSize - SectionHeaderSize) / RecordSize()` may not account for all record types or section layouts in sampling snapshots.
|
||||
|
||||
4. **Sampling vs tracing data layout differences.** The code may have been tested primarily with tracing snapshots. Sampling snapshots store data differently — verify that the same reader API works for both.
|
||||
|
||||
The fix should result in **thousands of nodes** for a typical 30-second sampling snapshot, not 202. If the current dotTrace API approach fundamentally can't extract sampling data at full fidelity, document that limitation and suggest an alternative approach (e.g., using dotTrace's built-in report export if available on macOS, or switching to a different API surface).
|
||||
|
||||
---
|
||||
|
||||
## Change 6: Add Time Unit to JSON Output
|
||||
|
||||
The current `inclusiveTime` / `exclusiveTime` values are in an unspecified unit (nanoseconds based on magnitude). Add a `timeUnit` field to the `snapshot` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"snapshot": {
|
||||
"path": "...",
|
||||
"payloadType": "time",
|
||||
"timeUnit": "nanoseconds",
|
||||
"threadCount": 11,
|
||||
"nodeCount": 1923
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Change 7: Add Summary Statistics
|
||||
|
||||
Add a `summary` section to the output with quick-reference stats:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"wallTimeMs": 30155,
|
||||
"activeTimeMs": 6340,
|
||||
"totalSamples": 15234,
|
||||
"topExclusiveMethod": "SendMessageNoFlush",
|
||||
"topExclusiveMs": 89.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This lets me immediately assess whether the profile captured meaningful work without parsing the full tree.
|
||||
|
||||
---
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. **Change 5** (missing nodes) — without this, everything else is moot
|
||||
2. **Change 4** (exclude idle) — makes hotspots immediately useful
|
||||
3. **Change 1** (increase limit) — more hotspots visible
|
||||
4. **Change 6** (time unit) — eliminates guesswork
|
||||
5. **Change 3** (flat paths) — most useful output format for analysis
|
||||
6. **Change 2** (filter) — nice to have for focused analysis
|
||||
7. **Change 7** (summary) — nice to have for quick assessment
|
||||
@@ -0,0 +1,297 @@
|
||||
# .NET 10 Optimization Opportunities for `NATS.Server`
|
||||
|
||||
This document identifies the highest-value places in the current .NET port that are still leaving performance on the table relative to what modern .NET 10 can do well. The focus is runtime behavior in the current codebase, not generic style guidance.
|
||||
|
||||
The ranking is based on likely payoff in NATS workloads:
|
||||
|
||||
1. Protocol parsing and per-message delivery paths
|
||||
2. Subscription matching and routing fanout
|
||||
3. JetStream storage hot paths
|
||||
4. Route, leaf, MQTT, and monitoring paths with avoidable allocation churn
|
||||
|
||||
Several areas already use `Span<T>`, `ReadOnlyMemory<byte>`, `SequenceReader<byte>`, and stack allocation correctly. The remaining gaps are mostly where the code falls back to `string`, `byte[]`, `List<T>`, `ToArray()`, LINQ, or repeated serialization work on hot paths.
|
||||
|
||||
## Detailed Implementation Plans
|
||||
|
||||
- [Parser span-retention plan](docs/plans/2026-03-13-optimizations_parser-plan.md)
|
||||
- [SubList allocation-reduction plan](docs/plans/2026-03-13-optimizations_sublist-plan.md)
|
||||
- [FileStore payload-and-index plan](docs/plans/2026-03-13-optimizations_filestore-plan.md)
|
||||
|
||||
## Highest ROI
|
||||
|
||||
### 1. Keep parser state in bytes/spans longer
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/Protocol/NatsParser.cs`
|
||||
- `src/NATS.Server/NatsClient.cs`
|
||||
- Current issue:
|
||||
- `NatsParser` tokenizes control lines with spans, but then converts subjects, reply subjects, queue names, SIDs, and JSON payloads into `string` and `byte[]` immediately.
|
||||
- `TryReadPayload()` always allocates a new `byte[]` and copies the payload, even when the underlying `ReadOnlySequence<byte>` is already usable.
|
||||
- `ParseConnect()` and `ParseInfo()` call `ToArray()` on the JSON portion.
|
||||
- Why it matters:
|
||||
- This runs for every client protocol command.
|
||||
- The parser sits directly on the publish/subscribe hot path, so small per-command allocations scale badly under fan-in.
|
||||
- Recommended optimization:
|
||||
- Introduce a split parsed representation:
|
||||
- a hot-path `ref struct` or `readonly struct` view carrying `ReadOnlySpan<byte>` / `ReadOnlySequence<byte>` slices for subject, reply, SID, queue, and payload
|
||||
- a slower materialization path only when code actually needs `string`
|
||||
- Store pending parser state as byte slices or pooled byte segments instead of `_pendingSubject` / `_pendingReplyTo` strings.
|
||||
- For single-segment payloads, hand through a `ReadOnlyMemory<byte>` slice rather than copying to a new array.
|
||||
- Only copy multi-segment payloads when required.
|
||||
- Use `SearchValues<byte>` for whitespace scanning and command detection instead of manual per-byte branching where it simplifies repeated searches.
|
||||
- .NET 10 techniques:
|
||||
- `ref struct`
|
||||
- `ReadOnlySpan<byte>`
|
||||
- `ReadOnlySequence<byte>`
|
||||
- `SearchValues<byte>`
|
||||
- `Encoding.ASCII.GetString(ReadOnlySpan<byte>)` only at materialization boundaries
|
||||
- Risk / complexity:
|
||||
- Medium to high. This touches command parsing contracts and downstream consumers.
|
||||
- Worth doing first because it reduces allocations before messages enter the rest of the server.
|
||||
|
||||
### 2. Remove string-heavy trie traversal in `SubList`
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/Subscriptions/SubList.cs`
|
||||
- `src/NATS.Server/Subscriptions/SubjectMatch.cs`
|
||||
- Current issue:
|
||||
- Insert/remove paths repeatedly call `token.ToString()`.
|
||||
- Routed subscription keys are synthesized as `"route|account|subject|queue"` strings and later split back with `Split('|')`.
|
||||
- Match path tokenization and cache population allocate arrays/lists and depend on string tokens.
|
||||
- `RemoveRemoteSubs()` and `RemoveRemoteSubsForAccount()` call `_remoteSubs.ToArray()` and re-parse keys on every sweep.
|
||||
- Why it matters:
|
||||
- `SubList.Match()` is one of the most performance-sensitive operations in the server.
|
||||
- Remote interest tracking becomes more expensive as the route/leaf topology grows.
|
||||
- Recommended optimization:
|
||||
- Replace composite routed-sub string keys with a dedicated value key:
|
||||
- `readonly record struct RoutedSubKey(string RouteId, string Account, string Subject, string? Queue)`
|
||||
- or a plain `readonly struct` with a custom comparer if profiling shows hash/comparison cost matters
|
||||
- Keep tokenized subjects in a pooled or cached token form for exact subjects.
|
||||
- Investigate a span-based token walker for matching so exact-subject lookups avoid `string[]` creation entirely.
|
||||
- Replace temporary `List<Subscription>` / `List<List<Subscription>>` creation in `Match()` with pooled builders or `ArrayBufferWriter<T>`.
|
||||
- For remote-sub cleanup, iterate dictionary entries without `ToArray()` and avoid `Split`.
|
||||
- .NET 10 techniques:
|
||||
- `readonly struct` / `readonly record struct` for composite keys
|
||||
- `ReadOnlySpan<char>` token parsing
|
||||
- pooled builders via `ArrayPool<T>` or `ArrayBufferWriter<T>`
|
||||
- Risk / complexity:
|
||||
- Medium. The data model change is straightforward; changing trie matching internals requires careful parity testing.
|
||||
|
||||
### 3. Eliminate avoidable message duplication in `FileStore`
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/JetStream/Storage/FileStore.cs`
|
||||
- `src/NATS.Server/JetStream/Storage/MsgBlock.cs`
|
||||
- `src/NATS.Server/JetStream/Storage/StoredMessage.cs`
|
||||
- Current issue:
|
||||
- `AppendAsync()` transforms payload for persistence and often also keeps another managed copy in `_messages`.
|
||||
- `StoreMsg()` creates a combined `byte[]` for headers + payload.
|
||||
- Many maintenance operations (`TrimToMaxMessages`, `PurgeEx`, `LoadLastBySubjectAsync`, `ListAsync`) use LINQ over `_messages.Values`, causing iterator allocations and repeated scans.
|
||||
- Snapshot creation base64-encodes transformed payloads, forcing extra copies.
|
||||
- Why it matters:
|
||||
- JetStream storage code runs continuously under persistence-heavy workloads.
|
||||
- It is both allocation-sensitive and memory-residency-sensitive.
|
||||
- Recommended optimization:
|
||||
- Split stored payload representation into:
|
||||
- persisted payload bytes
|
||||
- logical payload view
|
||||
- optional headers view
|
||||
- Avoid constructing concatenated header+payload arrays when the record format can encode both spans directly.
|
||||
- Rework `StoredMessage` so hot metadata stays compact; consider a smaller `readonly struct` for indexes/metadata while payload storage remains reference-based.
|
||||
- Replace LINQ scans in hot maintenance paths with explicit loops.
|
||||
- Add per-subject indexes or rolling pointers for operations currently implemented as full scans when those operations are expected to be common.
|
||||
- .NET 10 techniques:
|
||||
- `ReadOnlyMemory<byte>` slices over shared buffers
|
||||
- `readonly struct` for compact metadata/index entries
|
||||
- explicit loops over LINQ in storage hot paths
|
||||
- `CollectionsMarshal` where safe for dictionary/list access in tight loops
|
||||
- Risk / complexity:
|
||||
- High. This area needs careful correctness validation for retention, snapshots, and recovery.
|
||||
- High payoff for persistent streams.
|
||||
|
||||
### 4. Reduce formatting and copy overhead in route and leaf message sends
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/Routes/RouteConnection.cs`
|
||||
- `src/NATS.Server/LeafNodes/LeafConnection.cs`
|
||||
- Current issue:
|
||||
- Control lines are built with string interpolation, converted with `Encoding.ASCII.GetBytes`, then written separately from payload and trailer.
|
||||
- `"\r\n"u8.ToArray()` allocates for every send.
|
||||
- Batch protocol send methods build a `StringBuilder`, then allocate one big ASCII byte array.
|
||||
- Why it matters:
|
||||
- Cluster routes and leaf nodes are high-throughput transport paths in real deployments.
|
||||
- This code is not as hot as client publish fanout, but it is hot enough to matter under clustered load.
|
||||
- Recommended optimization:
|
||||
- Mirror the client path:
|
||||
- encode control lines into stackalloc or pooled byte buffers with span formatting
|
||||
- write control + payload + CRLF via scatter-gather (`ReadOnlyMemory<byte>[]`) or a reusable outbound buffer
|
||||
- Replace repeated CRLF arrays with a static `ReadOnlyMemory<byte>` / `ReadOnlySpan<byte>`.
|
||||
- For route sub protocol batches, encode directly into an `ArrayBufferWriter<byte>` instead of `StringBuilder` -> string -> bytes.
|
||||
- .NET 10 techniques:
|
||||
- `string.Create` or direct span formatting into pooled buffers
|
||||
- `ArrayBufferWriter<byte>`
|
||||
- scatter-gather writes where transport permits
|
||||
- Risk / complexity:
|
||||
- Medium. Mostly localized refactoring with low semantic risk.
|
||||
|
||||
## Medium ROI
|
||||
|
||||
### 5. Stop using LINQ-heavy materialization in monitoring endpoints
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/Monitoring/ConnzHandler.cs`
|
||||
- `src/NATS.Server/Monitoring/SubszHandler.cs`
|
||||
- Current issue:
|
||||
- Monitoring paths repeatedly call `ToArray()`, `Select()`, `Where()`, `OrderBy()`, `Skip()`, and `Take()`.
|
||||
- `SubszHandler` builds full subscription lists even when the request only needs counts.
|
||||
- `ConnzHandler` repeatedly rematerializes arrays while filtering and sorting.
|
||||
- Why it matters:
|
||||
- Monitoring endpoints are not the publish hot path, but they can become disruptive on busy servers with many clients/subscriptions.
|
||||
- These allocations are easy to avoid.
|
||||
- Recommended optimization:
|
||||
- Separate count-only and detail-request paths.
|
||||
- Use single-pass loops and pooled temporary lists.
|
||||
- Delay expensive subscription detail expansion until after paging when possible.
|
||||
- Consider returning immutable snapshots generated incrementally by the server for common monitor queries.
|
||||
- .NET 10 techniques:
|
||||
- explicit loops
|
||||
- pooled arrays/lists
|
||||
- `CollectionsMarshal.AsSpan()` for internal list traversal where safe
|
||||
- Risk / complexity:
|
||||
- Low to medium.
|
||||
|
||||
### 6. Modernize MQTT packet writing and text parsing
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/Mqtt/MqttPacketWriter.cs`
|
||||
- `src/NATS.Server/Mqtt/MqttProtocolParser.cs`
|
||||
- Current issue:
|
||||
- `MqttPacketWriter` returns fresh `byte[]` instances for every string/packet write.
|
||||
- Remaining-length encoding returns `scratch[..index].ToArray()`.
|
||||
- `ParseLine()` uses `Trim()`, `StartsWith()`, `Split()`, slicing, and string-based parsing throughout.
|
||||
- Why it matters:
|
||||
- MQTT is a side protocol, so this is not the top optimization target.
|
||||
- Still worth fixing because the code is currently allocation-heavy and straightforward to improve.
|
||||
- Recommended optimization:
|
||||
- Add `TryWrite...` APIs that write into caller-provided `Span<byte>` / `IBufferWriter<byte>`.
|
||||
- Keep remaining-length bytes on the stack and copy directly into the final destination buffer.
|
||||
- Rework `ParseLine()` to operate on `ReadOnlySpan<char>` and avoid `Split`.
|
||||
- .NET 10 techniques:
|
||||
- `Span<byte>`
|
||||
- `IBufferWriter<byte>`
|
||||
- `ReadOnlySpan<char>`
|
||||
- `SearchValues<char>` for token separators if useful
|
||||
- Risk / complexity:
|
||||
- Low.
|
||||
|
||||
### 7. Replace full scans over `_messages` with maintained indexes where operations are common
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/JetStream/Storage/FileStore.cs`
|
||||
- Current issue:
|
||||
- `LoadLastBySubjectAsync()` scans all messages, filters, sorts descending, and picks the first result.
|
||||
- `TrimToMaxMessages()` repeatedly calls `_messages.Keys.Min()`.
|
||||
- `PurgeEx()` materializes candidate lists before deletion.
|
||||
- Why it matters:
|
||||
- These are algorithmic inefficiencies, not just allocation issues.
|
||||
- They become more visible as streams grow.
|
||||
- Recommended optimization:
|
||||
- Maintain lightweight indexes:
|
||||
- last sequence by subject
|
||||
- first/last live sequence tracking without `Min()` / `Max()` scans
|
||||
- optionally per-subject linked or sorted sequence sets for purge/retention operations
|
||||
- If full indexing is too large a change, replace repeated LINQ scans with single-pass loops immediately.
|
||||
- .NET 10 techniques:
|
||||
- compact metadata structs
|
||||
- tighter dictionary usage
|
||||
- fewer transient enumerators
|
||||
- Risk / complexity:
|
||||
- Medium.
|
||||
|
||||
### 8. Reduce repeated small allocations in protocol constants and control frames
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/Protocol/NatsParser.cs`
|
||||
- `src/NATS.Server/Routes/RouteConnection.cs`
|
||||
- `src/NATS.Server/LeafNodes/LeafConnection.cs`
|
||||
- other transport helpers
|
||||
- Current issue:
|
||||
- Some constants are still materialized via `ToArray()` rather than held as static `byte[]` or `ReadOnlyMemory<byte>`.
|
||||
- Control frames repeatedly build temporary arrays for tiny literals.
|
||||
- Why it matters:
|
||||
- These are cheap wins and remove noisy allocation churn.
|
||||
- Recommended optimization:
|
||||
- Standardize on shared static byte literals for CRLF and fixed protocol tokens.
|
||||
- Audit for repeated `u8.ToArray()` or `Encoding.ASCII.GetBytes` on invariant text.
|
||||
- .NET 10 techniques:
|
||||
- static cached buffers
|
||||
- span-based concatenation into reusable destinations
|
||||
- Risk / complexity:
|
||||
- Low.
|
||||
|
||||
## Lower ROI Or Caution Areas
|
||||
|
||||
### 9. Be selective about introducing more structs
|
||||
|
||||
- Files:
|
||||
- cross-cutting
|
||||
- Current issue:
|
||||
- Some parts of the code would benefit from value types, but others already contain references (`string`, `byte[]`, `ReadOnlyMemory<byte>`, dictionaries) where converting whole models to structs would increase copying and call-site complexity.
|
||||
- Recommendation:
|
||||
- Good struct candidates:
|
||||
- composite dictionary keys
|
||||
- compact metadata/index entries
|
||||
- parser token views
|
||||
- queue or routing bookkeeping records
|
||||
- Poor struct candidates:
|
||||
- large mutable models
|
||||
- objects with many reference-type fields
|
||||
- stateful connection objects
|
||||
- Why it matters:
|
||||
- “Use more structs” is only a win when the values are small, immutable, and heavily allocated.
|
||||
|
||||
### 10. Avoid premature replacement of already-good memory APIs
|
||||
|
||||
- Files:
|
||||
- `src/NATS.Server/NatsClient.cs`
|
||||
- `src/NATS.Server/IO/OutboundBufferPool.cs`
|
||||
- several JetStream codecs
|
||||
- Current issue:
|
||||
- There has already been meaningful optimization work in direct write buffering and pooled outbound paths.
|
||||
- Replacing these with more exotic abstractions without profiling could regress behavior.
|
||||
- Recommendation:
|
||||
- Prefer extending the current buffer-pool and direct-write patterns into routes, leaves, and parser payload handling before redesigning the client write path again.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. `NatsParser` hot-path byte retention and reduced payload copying
|
||||
2. `SubList` key/token allocation cleanup and remote-sub key redesign
|
||||
3. Route/leaf outbound buffer encoding cleanup
|
||||
4. `FileStore` hot-path de-LINQ and payload/index refactoring
|
||||
5. Monitoring endpoint de-materialization
|
||||
6. MQTT writer/parser span-based cleanup
|
||||
|
||||
## What To Measure Before And After
|
||||
|
||||
Use the benchmark project and targeted microbenchmarks to measure:
|
||||
|
||||
- allocations per `PUB`, `SUB`, `UNSUB`, `CONNECT`
|
||||
- allocations per delivered message under fanout
|
||||
- `SubList.Match()` throughput and allocations for:
|
||||
- exact subjects
|
||||
- wildcard subjects
|
||||
- queue subscriptions
|
||||
- remote interest present
|
||||
- JetStream append throughput and bytes allocated per append
|
||||
- route/leaf forwarded-message allocations
|
||||
- monitoring endpoint allocations for large client/subscription sets
|
||||
|
||||
## Summary
|
||||
|
||||
The best remaining gains are not from sprinkling `Span<T>` everywhere. They come from carrying byte-oriented data further through the hot paths, removing composite-string bookkeeping, reducing duplicate payload ownership in JetStream storage, and eliminating materialization-heavy helper code around routing and monitoring.
|
||||
|
||||
If you only do three things, do these first:
|
||||
|
||||
1. Rework `NatsParser` to avoid early `string` / `byte[]` creation.
|
||||
2. Replace `SubList` composite string keys and string-heavy token handling.
|
||||
3. Refactor `FileStore` and route/leaf send paths to reduce duplicate buffers and transient formatting allocations.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,14 +10,49 @@ public sealed class Account : IDisposable
|
||||
public const string SystemAccountName = "$SYS";
|
||||
public const string ClientInfoHdr = "Nats-Request-Info";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logical account name used for tenant isolation and subject scoping.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the subscription index for this account's subject interest.
|
||||
/// </summary>
|
||||
public SubList SubList { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets default publish/subscribe permissions applied to new clients in this account.
|
||||
/// </summary>
|
||||
public Permissions? DefaultPermissions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum concurrent client connections for this account; `0` means unlimited.
|
||||
/// </summary>
|
||||
public int MaxConnections { get; set; } // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum subscriptions allowed for this account; `0` means unlimited.
|
||||
/// </summary>
|
||||
public int MaxSubscriptions { get; set; } // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Gets the export configuration (services/streams) this account exposes to other accounts.
|
||||
/// </summary>
|
||||
public ExportMap Exports { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the import configuration (services/streams) this account consumes from other accounts.
|
||||
/// </summary>
|
||||
public ImportMap Imports { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the legacy maximum number of JetStream streams; `0` means unlimited.
|
||||
/// </summary>
|
||||
public int MaxJetStreamStreams { get; set; } // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the assigned JetStream resource tier name for policy-driven limits.
|
||||
/// </summary>
|
||||
public string? JetStreamTier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -31,8 +66,19 @@ public sealed class Account : IDisposable
|
||||
public AccountLimits JetStreamLimits { get; set; } = AccountLimits.Unlimited;
|
||||
|
||||
// JWT fields
|
||||
/// <summary>
|
||||
/// Gets or sets the account NKey identity from JWT/account configuration.
|
||||
/// </summary>
|
||||
public string? Nkey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the issuer key that signed account claims for this account.
|
||||
/// </summary>
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets signing keys trusted for delegated account claim updates.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -40,6 +86,11 @@ public sealed class Account : IDisposable
|
||||
/// <remarks>Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934).</remarks>
|
||||
private const string GlobalRevocationKey = "*";
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a user NKey at or before a specified issued-at timestamp.
|
||||
/// </summary>
|
||||
/// <param name="userNkey">User NKey to revoke.</param>
|
||||
/// <param name="issuedAt">Maximum issued-at timestamp (Unix seconds) that is still considered revoked.</param>
|
||||
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +99,14 @@ public sealed class Account : IDisposable
|
||||
/// up to the given timestamp.
|
||||
/// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887).
|
||||
/// </summary>
|
||||
/// <param name="issuedBefore">JWT issued-at cutoff (Unix seconds) for global revocation.</param>
|
||||
public void RevokeAllUsers(long issuedBefore) => _revokedUsers[GlobalRevocationKey] = issuedBefore;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user token is revoked either directly or by global revocation.
|
||||
/// </summary>
|
||||
/// <param name="userNkey">User NKey being evaluated.</param>
|
||||
/// <param name="issuedAt">JWT issued-at timestamp (Unix seconds) to compare against revocation cutoffs.</param>
|
||||
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||
{
|
||||
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||
@@ -74,6 +131,7 @@ public sealed class Account : IDisposable
|
||||
/// Removes the revocation entry for <paramref name="userNkey"/>.
|
||||
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||
/// </summary>
|
||||
/// <param name="userNkey">User NKey whose revocation record should be removed.</param>
|
||||
public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _);
|
||||
|
||||
/// <summary>Removes all revocation entries, including any global ("*") revocation.</summary>
|
||||
@@ -89,18 +147,42 @@ public sealed class Account : IDisposable
|
||||
private int _consumerCount;
|
||||
private long _storageUsed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an account namespace for isolated subscriptions, imports, and exports.
|
||||
/// </summary>
|
||||
/// <param name="name">Unique account name.</param>
|
||||
public Account(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of currently connected clients in this account.
|
||||
/// </summary>
|
||||
public int ClientCount => _clients.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active subscriptions tracked for this account.
|
||||
/// </summary>
|
||||
public int SubscriptionCount => Volatile.Read(ref _subscriptionCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reserved JetStream stream slots for this account.
|
||||
/// </summary>
|
||||
public int JetStreamStreamCount => Volatile.Read(ref _jetStreamStreamCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reserved JetStream consumer slots for this account.
|
||||
/// </summary>
|
||||
public int ConsumerCount => Volatile.Read(ref _consumerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracked JetStream storage usage in bytes for this account.
|
||||
/// </summary>
|
||||
public long StorageUsed => Interlocked.Read(ref _storageUsed);
|
||||
|
||||
/// <summary>Returns false if max connections exceeded.</summary>
|
||||
/// <param name="clientId">Client identifier to register in this account.</param>
|
||||
public bool AddClient(ulong clientId)
|
||||
{
|
||||
if (MaxConnections > 0 && _clients.Count >= MaxConnections)
|
||||
@@ -109,8 +191,15 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client connection from this account's active client set.
|
||||
/// </summary>
|
||||
/// <param name="clientId">Client identifier to remove.</param>
|
||||
public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to increment the subscription count while honoring account limits.
|
||||
/// </summary>
|
||||
public bool IncrementSubscriptions()
|
||||
{
|
||||
if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
|
||||
@@ -119,6 +208,9 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the subscription count after an unsubscribe/removal.
|
||||
/// </summary>
|
||||
public void DecrementSubscriptions()
|
||||
{
|
||||
Interlocked.Decrement(ref _subscriptionCount);
|
||||
@@ -141,6 +233,9 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases one previously reserved JetStream stream slot.
|
||||
/// </summary>
|
||||
public void ReleaseStream()
|
||||
{
|
||||
if (Volatile.Read(ref _jetStreamStreamCount) == 0)
|
||||
@@ -160,6 +255,9 @@ public sealed class Account : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases one previously reserved JetStream consumer slot.
|
||||
/// </summary>
|
||||
public void ReleaseConsumer()
|
||||
{
|
||||
if (Volatile.Read(ref _consumerCount) == 0)
|
||||
@@ -173,6 +271,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns false if the positive delta would exceed <see cref="AccountLimits.MaxStorage"/>.
|
||||
/// A negative delta always succeeds.
|
||||
/// </summary>
|
||||
/// <param name="deltaBytes">Signed byte delta to apply to tracked storage usage.</param>
|
||||
public bool TrackStorageDelta(long deltaBytes)
|
||||
{
|
||||
var maxStorage = JetStreamLimits.MaxStorage;
|
||||
@@ -193,6 +292,9 @@ public sealed class Account : IDisposable
|
||||
// Reference: Go server/accounts.go — account generation tracking for permission invalidation.
|
||||
private long _generationId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the permission-generation value used to invalidate per-client caches.
|
||||
/// </summary>
|
||||
public long GenerationId => Interlocked.Read(ref _generationId);
|
||||
|
||||
/// <summary>Increments the generation counter, signalling that permission caches are stale.</summary>
|
||||
@@ -202,10 +304,19 @@ public sealed class Account : IDisposable
|
||||
// Go reference: server/client.go — handleSlowConsumer, markConnAsSlow, server/accounts.go slowConsumerCount
|
||||
private long _slowConsumerCount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of clients marked as slow consumers in this account.
|
||||
/// </summary>
|
||||
public long SlowConsumerCount => Interlocked.Read(ref _slowConsumerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Increments the slow-consumer counter for this account.
|
||||
/// </summary>
|
||||
public void IncrementSlowConsumers() => Interlocked.Increment(ref _slowConsumerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Resets the slow-consumer counter to zero.
|
||||
/// </summary>
|
||||
public void ResetSlowConsumerCount() => Interlocked.Exchange(ref _slowConsumerCount, 0L);
|
||||
|
||||
// Per-account message/byte stats
|
||||
@@ -214,17 +325,42 @@ public sealed class Account : IDisposable
|
||||
private long _inBytes;
|
||||
private long _outBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets total inbound messages observed for this account.
|
||||
/// </summary>
|
||||
public long InMsgs => Interlocked.Read(ref _inMsgs);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total outbound messages observed for this account.
|
||||
/// </summary>
|
||||
public long OutMsgs => Interlocked.Read(ref _outMsgs);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total inbound payload bytes observed for this account.
|
||||
/// </summary>
|
||||
public long InBytes => Interlocked.Read(ref _inBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets total outbound payload bytes observed for this account.
|
||||
/// </summary>
|
||||
public long OutBytes => Interlocked.Read(ref _outBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Adds inbound traffic counters for account-level monitoring.
|
||||
/// </summary>
|
||||
/// <param name="msgs">Number of inbound messages to add.</param>
|
||||
/// <param name="bytes">Number of inbound bytes to add.</param>
|
||||
public void IncrementInbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _inMsgs, msgs);
|
||||
Interlocked.Add(ref _inBytes, bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds outbound traffic counters for account-level monitoring.
|
||||
/// </summary>
|
||||
/// <param name="msgs">Number of outbound messages to add.</param>
|
||||
/// <param name="bytes">Number of outbound bytes to add.</param>
|
||||
public void IncrementOutbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _outMsgs, msgs);
|
||||
@@ -234,6 +370,10 @@ public sealed class Account : IDisposable
|
||||
// Internal (ACCOUNT) client for import/export message routing
|
||||
private InternalClient? _internalClient;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the account-scoped internal client used for import/export routing.
|
||||
/// </summary>
|
||||
/// <param name="clientId">Client ID to use when creating the internal account client.</param>
|
||||
public InternalClient GetOrCreateInternalClient(ulong clientId)
|
||||
{
|
||||
if (_internalClient != null) return _internalClient;
|
||||
@@ -243,9 +383,13 @@ public sealed class Account : IDisposable
|
||||
|
||||
// Service export latency tracking
|
||||
// Go reference: accounts.go serviceLatency / serviceExportLatencyStats.
|
||||
/// <summary>
|
||||
/// Gets the service latency tracker for this account's exported services.
|
||||
/// </summary>
|
||||
public ServiceLatencyTracker LatencyTracker { get; } = new();
|
||||
|
||||
/// <summary>Records a service request latency sample on this account's tracker.</summary>
|
||||
/// <param name="latencyMs">Observed service latency in milliseconds.</param>
|
||||
public void RecordServiceLatency(double latencyMs) => LatencyTracker.RecordLatency(latencyMs);
|
||||
|
||||
/// <summary>
|
||||
@@ -265,6 +409,7 @@ public sealed class Account : IDisposable
|
||||
/// Does not apply wildcard matching.
|
||||
/// Go reference: accounts.go getServiceExport (direct map lookup only).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to resolve.</param>
|
||||
public ServiceExportInfo? GetExactServiceExport(string subject)
|
||||
{
|
||||
if (Exports.Services.TryGetValue(subject, out var se))
|
||||
@@ -277,6 +422,7 @@ public sealed class Account : IDisposable
|
||||
/// wildcard matching. Returns null when no export pattern matches.
|
||||
/// Go reference: accounts.go getWildcardServiceExport (line 2849).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to match against export patterns.</param>
|
||||
public ServiceExportInfo? GetWildcardServiceExport(string subject)
|
||||
{
|
||||
// First try exact match
|
||||
@@ -296,6 +442,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns true when any service export (exact or wildcard) matches the given subject.
|
||||
/// Go reference: accounts.go getServiceExport.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to test.</param>
|
||||
public bool HasServiceExport(string subject) => GetWildcardServiceExport(subject) != null;
|
||||
|
||||
private static ServiceExportInfo ToServiceExportInfo(string subject, ServiceExport se)
|
||||
@@ -307,6 +454,13 @@ public sealed class Account : IDisposable
|
||||
return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a service export for cross-account request forwarding.
|
||||
/// </summary>
|
||||
/// <param name="subject">Exported service subject or subject pattern.</param>
|
||||
/// <param name="responseType">Response policy for this service export.</param>
|
||||
/// <param name="approved">Optional set of accounts authorized to import this service.</param>
|
||||
/// <param name="latency">Optional latency tracking configuration for this export.</param>
|
||||
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved, ServiceLatency? latency = null)
|
||||
{
|
||||
var auth = new ExportAuth
|
||||
@@ -322,6 +476,11 @@ public sealed class Account : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a stream export for cross-account stream delivery.
|
||||
/// </summary>
|
||||
/// <param name="subject">Exported stream subject or subject pattern.</param>
|
||||
/// <param name="approved">Optional set of accounts authorized to import this stream.</param>
|
||||
public void AddStreamExport(string subject, IEnumerable<Account>? approved)
|
||||
{
|
||||
var auth = new ExportAuth
|
||||
@@ -335,6 +494,9 @@ public sealed class Account : IDisposable
|
||||
/// Adds a service import with cycle detection.
|
||||
/// Go reference: accounts.go addServiceImport with checkForImportCycle.
|
||||
/// </summary>
|
||||
/// <param name="destination">Exporter account that owns the target service export.</param>
|
||||
/// <param name="from">Importer-visible subject pattern.</param>
|
||||
/// <param name="to">Exporter service subject to route to.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if no export found or import would create a cycle.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown if this account is not authorized.</exception>
|
||||
public ServiceImport AddServiceImport(Account destination, string from, string to)
|
||||
@@ -364,12 +526,19 @@ public sealed class Account : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Removes a service import by its 'from' subject.</summary>
|
||||
/// <param name="from">Importer-visible subject used when the import was created.</param>
|
||||
/// <returns>True if the import was found and removed.</returns>
|
||||
public bool RemoveServiceImport(string from)
|
||||
{
|
||||
return Imports.Services.Remove(from);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stream import so this account can consume another account's exported stream subjects.
|
||||
/// </summary>
|
||||
/// <param name="source">Exporter account that owns the stream export.</param>
|
||||
/// <param name="from">Exporter stream subject to import from.</param>
|
||||
/// <param name="to">Importer-local subject alias for the stream import.</param>
|
||||
public void AddStreamImport(Account source, string from, string to)
|
||||
{
|
||||
if (!source.Exports.Streams.TryGetValue(from, out var export))
|
||||
@@ -389,6 +558,7 @@ public sealed class Account : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Removes a stream import by its 'from' subject.</summary>
|
||||
/// <param name="from">Importer-visible subject used when the stream import was created.</param>
|
||||
/// <returns>True if the import was found and removed.</returns>
|
||||
public bool RemoveStreamImport(string from)
|
||||
{
|
||||
@@ -404,6 +574,7 @@ public sealed class Account : IDisposable
|
||||
/// Uses DFS through the stream import graph starting at proposedSource, checking if any path leads back to this account.
|
||||
/// Go reference: accounts.go streamImportFormsCycle / checkStreamImportsForCycles.
|
||||
/// </summary>
|
||||
/// <param name="proposedSource">Source account being considered for a new stream import.</param>
|
||||
public bool StreamImportFormsCycle(Account proposedSource)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposedSource);
|
||||
@@ -448,11 +619,15 @@ public sealed class Account : IDisposable
|
||||
/// <summary>
|
||||
/// Returns true if this account has at least one stream import from the account with the given name.
|
||||
/// </summary>
|
||||
/// <param name="accountName">Source account name to check for stream-import relationships.</param>
|
||||
public bool HasStreamImportFrom(string accountName) =>
|
||||
Imports.Streams.Exists(si => string.Equals(si.SourceAccount.Name, accountName, StringComparison.Ordinal));
|
||||
|
||||
// Per-subject service response thresholds.
|
||||
// Go reference: server/accounts.go — serviceExport.respThresh, SetServiceExportResponseThreshold, ServiceExportResponseThreshold.
|
||||
/// <summary>
|
||||
/// Gets per-subject response-time thresholds used for service export SLA checks.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, TimeSpan> ServiceResponseThresholds { get; } =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
@@ -460,6 +635,8 @@ public sealed class Account : IDisposable
|
||||
/// Sets the maximum time a service export responder may take to reply.
|
||||
/// Go reference: accounts.go SetServiceExportResponseThreshold (~line 2522).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject whose threshold is being set.</param>
|
||||
/// <param name="threshold">Maximum allowed response time before a request is considered overdue.</param>
|
||||
public void SetServiceResponseThreshold(string subject, TimeSpan threshold) =>
|
||||
ServiceResponseThresholds[subject] = threshold;
|
||||
|
||||
@@ -467,6 +644,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns the threshold for <paramref name="subject"/>, or <see langword="null"/> if none is set.
|
||||
/// Go reference: accounts.go ServiceExportResponseThreshold (~line 2510).
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to query for an explicit threshold.</param>
|
||||
public TimeSpan? GetServiceResponseThreshold(string subject) =>
|
||||
ServiceResponseThresholds.TryGetValue(subject, out var t) ? t : null;
|
||||
|
||||
@@ -475,6 +653,8 @@ public sealed class Account : IDisposable
|
||||
/// for <paramref name="subject"/>. When no threshold is set the response is never considered overdue.
|
||||
/// Go reference: accounts.go — respThresh check inside response-timer logic.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to evaluate.</param>
|
||||
/// <param name="elapsed">Observed response latency for the service request.</param>
|
||||
public bool IsServiceResponseOverdue(string subject, TimeSpan elapsed)
|
||||
{
|
||||
if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold))
|
||||
@@ -486,6 +666,8 @@ public sealed class Account : IDisposable
|
||||
/// Combines threshold lookup and overdue check into a single result.
|
||||
/// Go reference: accounts.go — ServiceExportResponseThreshold + response-timer logic.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service subject to evaluate.</param>
|
||||
/// <param name="elapsed">Observed response latency for the service request.</param>
|
||||
public ServiceResponseThresholdResult CheckServiceResponse(string subject, TimeSpan elapsed)
|
||||
{
|
||||
if (!ServiceResponseThresholds.TryGetValue(subject, out var threshold))
|
||||
@@ -552,6 +734,7 @@ public sealed class Account : IDisposable
|
||||
/// Sets the UTC expiration time for this account.
|
||||
/// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment.
|
||||
/// </summary>
|
||||
/// <param name="expiresAtUtc">UTC timestamp when the account should expire.</param>
|
||||
public void SetExpiration(DateTime expiresAtUtc) =>
|
||||
Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks);
|
||||
|
||||
@@ -562,6 +745,7 @@ public sealed class Account : IDisposable
|
||||
/// Convenience method: sets the expiration to <c>DateTime.UtcNow + <paramref name="ttl"/></c>.
|
||||
/// Go reference: accounts.go — SetExpirationTimer with duration argument.
|
||||
/// </summary>
|
||||
/// <param name="ttl">Duration from now until account expiration.</param>
|
||||
public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl);
|
||||
|
||||
/// <summary>
|
||||
@@ -589,6 +773,8 @@ public sealed class Account : IDisposable
|
||||
/// Registers a JWT activation claim for the given subject.
|
||||
/// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service or stream subject associated with the activation token.</param>
|
||||
/// <param name="claim">Activation claim metadata including issued/expiry timestamps.</param>
|
||||
public void RegisterActivation(string subject, ActivationClaim claim) =>
|
||||
_activations[subject] = claim;
|
||||
|
||||
@@ -597,6 +783,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns a result indicating whether the claim was found and whether it is expired.
|
||||
/// Go reference: accounts.go — checkActivation (~line 2943): act.Expires <= tn ⇒ expired.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service or stream subject whose activation should be checked.</param>
|
||||
public ActivationCheckResult CheckActivationExpiry(string subject)
|
||||
{
|
||||
if (!_activations.TryGetValue(subject, out var claim))
|
||||
@@ -612,6 +799,7 @@ public sealed class Account : IDisposable
|
||||
/// and has passed its expiry time.
|
||||
/// Go reference: accounts.go — act.Expires <= tn check inside checkActivation.
|
||||
/// </summary>
|
||||
/// <param name="subject">Service or stream subject whose activation should be checked.</param>
|
||||
public bool IsActivationExpired(string subject) =>
|
||||
_activations.TryGetValue(subject, out var claim) && claim.IsExpired;
|
||||
|
||||
@@ -683,6 +871,7 @@ public sealed class Account : IDisposable
|
||||
/// incremented so that per-client permission caches are invalidated.
|
||||
/// Go reference: server/accounts.go UpdateAccountClaims / updateAccountClaimsWithRefresh (~line 3287).
|
||||
/// </summary>
|
||||
/// <param name="newClaims">Fresh account claim snapshot to apply.</param>
|
||||
public AccountClaimUpdateResult UpdateAccountClaims(AccountClaimData newClaims)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newClaims);
|
||||
@@ -751,6 +940,9 @@ public sealed class Account : IDisposable
|
||||
/// Records which origin account and original reply subject to route the response back to.
|
||||
/// Go reference: accounts.go addRespMapEntry.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Rewritten reply subject used while routing through service imports.</param>
|
||||
/// <param name="originAccount">Original requester account name for return routing.</param>
|
||||
/// <param name="originalReply">Original reply subject to restore before delivery.</param>
|
||||
public void AddReverseRespMapEntry(string replySubject, string originAccount, string originalReply) =>
|
||||
_reverseResponseMap[replySubject] = new ReverseResponseMapEntry(
|
||||
replySubject, originAccount, originalReply, DateTime.UtcNow);
|
||||
@@ -760,6 +952,7 @@ public sealed class Account : IDisposable
|
||||
/// Returns <see langword="null"/> when no mapping exists.
|
||||
/// Go reference: accounts.go checkForReverseEntries.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Rewritten reply subject to resolve back to origin details.</param>
|
||||
public ReverseResponseMapEntry? CheckForReverseEntries(string replySubject) =>
|
||||
_reverseResponseMap.TryGetValue(replySubject, out var entry) ? entry : null;
|
||||
|
||||
@@ -767,6 +960,7 @@ public sealed class Account : IDisposable
|
||||
/// Removes the reverse response mapping for <paramref name="replySubject"/>.
|
||||
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Rewritten reply subject whose reverse mapping should be removed.</param>
|
||||
public bool RemoveReverseRespMapEntry(string replySubject) =>
|
||||
_reverseResponseMap.TryRemove(replySubject, out _);
|
||||
|
||||
@@ -785,6 +979,7 @@ public sealed class Account : IDisposable
|
||||
/// from receiving them.
|
||||
/// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
/// </summary>
|
||||
/// <param name="importSubject">Service import subject to test for local shadowing.</param>
|
||||
public bool ServiceImportShadowed(string importSubject)
|
||||
{
|
||||
var matchResult = SubList.Match(importSubject);
|
||||
@@ -795,12 +990,14 @@ public sealed class Account : IDisposable
|
||||
/// Returns true if this account has at least one matching subscription for the given subject.
|
||||
/// Go reference: accounts.go SubscriptionInterest.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to test for local subscription interest.</param>
|
||||
public bool SubscriptionInterest(string subject) => Interest(subject) > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of matching subscriptions (plain + queue) for the given subject.
|
||||
/// Go reference: accounts.go Interest.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to count matching local subscriptions for.</param>
|
||||
public int Interest(string subject)
|
||||
{
|
||||
var (plainCount, queueCount) = SubList.NumInterest(subject);
|
||||
@@ -818,6 +1015,7 @@ public sealed class Account : IDisposable
|
||||
/// When <paramref name="filter"/> is empty, counts all mappings.
|
||||
/// Go reference: accounts.go NumPendingResponses.
|
||||
/// </summary>
|
||||
/// <param name="filter">Optional service subject filter; empty counts all response mappings.</param>
|
||||
public int NumPendingResponses(string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
@@ -847,6 +1045,8 @@ public sealed class Account : IDisposable
|
||||
/// Removes a response service import mapping.
|
||||
/// Go reference: accounts.go removeRespServiceImport.
|
||||
/// </summary>
|
||||
/// <param name="serviceImport">Response service import instance to remove.</param>
|
||||
/// <param name="reason">Reason code for observability/metrics of the removal.</param>
|
||||
public void RemoveRespServiceImport(ServiceImport? serviceImport, ResponseServiceImportRemovalReason reason = ResponseServiceImportRemovalReason.Ok)
|
||||
{
|
||||
if (serviceImport == null)
|
||||
@@ -924,6 +1124,7 @@ public sealed class Account : IDisposable
|
||||
/// including the list of local subscription subjects that shadow it.
|
||||
/// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
/// </summary>
|
||||
/// <param name="importSubject">Service import subject to inspect for shadowing details.</param>
|
||||
public ShadowCheckResult CheckServiceImportShadowing(string importSubject)
|
||||
{
|
||||
var matchResult = SubList.Match(importSubject);
|
||||
@@ -940,6 +1141,9 @@ public sealed class Account : IDisposable
|
||||
return new ShadowCheckResult(isShadowed, importSubject, shadowingSubs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes account-owned resources, including the subscription index.
|
||||
/// </summary>
|
||||
public void Dispose() => SubList.Dispose();
|
||||
}
|
||||
|
||||
@@ -1006,9 +1210,24 @@ public sealed record RevocationInfo(
|
||||
/// </summary>
|
||||
public sealed class ActivationClaim
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the activated subject path this claim authorizes.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the activation was issued.
|
||||
/// </summary>
|
||||
public required DateTime IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the activation expires.
|
||||
/// </summary>
|
||||
public required DateTime ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issuer key associated with this activation claim.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,8 +2,11 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class AccountConfig
|
||||
{
|
||||
/// <summary>Maximum concurrent client connections allowed for this account (0 = unlimited).</summary>
|
||||
public int MaxConnections { get; init; } // 0 = unlimited
|
||||
/// <summary>Maximum subscriptions per client/account context (0 = unlimited).</summary>
|
||||
public int MaxSubscriptions { get; init; } // 0 = unlimited
|
||||
/// <summary>Default publish/subscribe permissions applied to users in this account.</summary>
|
||||
public Permissions? DefaultPermissions { get; init; }
|
||||
|
||||
/// <summary>Service and stream exports from this account.</summary>
|
||||
@@ -19,7 +22,9 @@ public sealed class AccountConfig
|
||||
/// </summary>
|
||||
public sealed class ExportDefinition
|
||||
{
|
||||
/// <summary>Service subject exported to other accounts.</summary>
|
||||
public string? Service { get; init; }
|
||||
/// <summary>Stream subject exported to other accounts.</summary>
|
||||
public string? Stream { get; init; }
|
||||
|
||||
/// <summary>Optional latency tracking subject (e.g. "latency.svc.echo").</summary>
|
||||
@@ -36,9 +41,14 @@ public sealed class ExportDefinition
|
||||
/// </summary>
|
||||
public sealed class ImportDefinition
|
||||
{
|
||||
/// <summary>Remote account name for imported service mappings.</summary>
|
||||
public string? ServiceAccount { get; init; }
|
||||
/// <summary>Remote service subject imported from <see cref="ServiceAccount"/>.</summary>
|
||||
public string? ServiceSubject { get; init; }
|
||||
/// <summary>Remote account name for imported stream mappings.</summary>
|
||||
public string? StreamAccount { get; init; }
|
||||
/// <summary>Remote stream subject imported from <see cref="StreamAccount"/>.</summary>
|
||||
public string? StreamSubject { get; init; }
|
||||
/// <summary>Local remapped subject for imported services/streams.</summary>
|
||||
public string? To { get; init; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ public static class AccountImportExport
|
||||
/// Returns true if following service imports from <paramref name="from"/>
|
||||
/// eventually leads back to <paramref name="to"/>.
|
||||
/// </summary>
|
||||
/// <param name="from">Starting account whose service-import edges are traversed.</param>
|
||||
/// <param name="to">Target account that would indicate an import cycle if reached.</param>
|
||||
/// <param name="visited">Visited account-name set used to avoid infinite graph recursion.</param>
|
||||
public static bool DetectCycle(Account from, Account to, HashSet<string>? visited = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(from);
|
||||
@@ -48,6 +51,9 @@ public static class AccountImportExport
|
||||
/// <summary>
|
||||
/// Validates that the import is authorized and does not create a cycle.
|
||||
/// </summary>
|
||||
/// <param name="importingAccount">Account requesting to import a service.</param>
|
||||
/// <param name="exportingAccount">Account exporting the requested service subject.</param>
|
||||
/// <param name="exportSubject">Exported service subject being imported.</param>
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown when the importing account is not authorized.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the import would create a cycle.</exception>
|
||||
public static void ValidateImport(Account importingAccount, Account exportingAccount, string exportSubject)
|
||||
|
||||
@@ -2,6 +2,11 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public interface IExternalAuthClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests an allow/deny decision from an external authentication provider.
|
||||
/// </summary>
|
||||
/// <param name="request">Credential material and identity hints from the client connection.</param>
|
||||
/// <param name="ct">Cancellation token bound to auth timeout and connection lifecycle.</param>
|
||||
Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -19,14 +24,36 @@ public record ExternalAuthDecision(
|
||||
|
||||
public sealed class ExternalAuthOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether external auth callouts are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout budget for each external auth decision request.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client implementation responsible for external auth decisions.
|
||||
/// </summary>
|
||||
public IExternalAuthClient? Client { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProxyAuthOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether trusted-proxy authentication mode is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the required username prefix marking identities provided by a trusted proxy.
|
||||
/// </summary>
|
||||
public string UsernamePrefix { get; set; } = "proxy:";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default account to assign when proxy-authenticated users omit one.
|
||||
/// </summary>
|
||||
public string? Account { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,10 +2,33 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class AuthResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the resolved client identity that successfully authenticated.
|
||||
/// </summary>
|
||||
public required string Identity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the account name assigned to the authenticated identity.
|
||||
/// </summary>
|
||||
public string? AccountName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets effective publish/subscribe permissions applied to the connection.
|
||||
/// </summary>
|
||||
public Permissions? Permissions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the credential expiry timestamp after which the connection should be considered invalid.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Expiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of JetStream streams permitted for this identity.
|
||||
/// </summary>
|
||||
public int MaxJetStreamStreams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JetStream tier assigned for quota enforcement.
|
||||
/// </summary>
|
||||
public string? JetStreamTier { get; init; }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,14 @@ public sealed class AuthService
|
||||
private readonly string? _noAuthUser;
|
||||
private readonly Dictionary<string, User>? _usersMap;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any authentication mechanism is configured.
|
||||
/// </summary>
|
||||
public bool IsAuthRequired { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the protocol must issue a nonce challenge.
|
||||
/// </summary>
|
||||
public bool NonceRequired { get; }
|
||||
|
||||
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
|
||||
@@ -28,6 +35,10 @@ public sealed class AuthService
|
||||
_usersMap = usersMap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an authentication service from server options and configured auth sources.
|
||||
/// </summary>
|
||||
/// <param name="options">Server options containing static users, tokens, NKeys, and auth extensions.</param>
|
||||
public static AuthService Build(NatsOptions options)
|
||||
{
|
||||
var authenticators = new List<IAuthenticator>();
|
||||
@@ -97,6 +108,10 @@ public sealed class AuthService
|
||||
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to authenticate a client CONNECT context against configured authenticators.
|
||||
/// </summary>
|
||||
/// <param name="context">Client auth context extracted from CONNECT and transport metadata.</param>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
if (!IsAuthRequired)
|
||||
@@ -145,6 +160,9 @@ public sealed class AuthService
|
||||
return new AuthResult { Identity = _noAuthUser };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates cryptographically strong nonce bytes for NKey/JWT signature challenges.
|
||||
/// </summary>
|
||||
public byte[] GenerateNonce()
|
||||
{
|
||||
Span<byte> raw = stackalloc byte[11];
|
||||
@@ -152,6 +170,13 @@ public sealed class AuthService
|
||||
return raw.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates MQTT username/password fields against configured MQTT auth settings.
|
||||
/// </summary>
|
||||
/// <param name="configuredUsername">Username configured on the server for MQTT auth.</param>
|
||||
/// <param name="configuredPassword">Password configured on the server for MQTT auth.</param>
|
||||
/// <param name="providedUsername">Username supplied by the connecting MQTT client.</param>
|
||||
/// <param name="providedPassword">Password supplied by the connecting MQTT client.</param>
|
||||
public static bool ValidateMqttCredentials(
|
||||
string? configuredUsername,
|
||||
string? configuredPassword,
|
||||
@@ -165,6 +190,10 @@ public sealed class AuthService
|
||||
&& string.Equals(configuredPassword, providedPassword, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes nonce bytes into URL-safe base64 format used by NATS auth challenges.
|
||||
/// </summary>
|
||||
/// <param name="nonce">Raw nonce bytes generated for the challenge.</param>
|
||||
public string EncodeNonce(byte[] nonce)
|
||||
{
|
||||
return Convert.ToBase64String(nonce)
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class ClientPermissions : IDisposable
|
||||
_responseTracker = responseTracker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a runtime client-permissions evaluator from account/user permission config.
|
||||
/// </summary>
|
||||
/// <param name="permissions">Permission configuration from auth claims or static config.</param>
|
||||
public static ClientPermissions? Build(Permissions? permissions)
|
||||
{
|
||||
if (permissions == null)
|
||||
@@ -33,8 +37,11 @@ public sealed class ClientPermissions : IDisposable
|
||||
return new ClientPermissions(pub, sub, responseTracker);
|
||||
}
|
||||
|
||||
/// <summary>Optional tracker used to authorize dynamic response subjects.</summary>
|
||||
public ResponseTracker? ResponseTracker => _responseTracker;
|
||||
|
||||
/// <summary>Determines whether publishing to the given subject is permitted.</summary>
|
||||
/// <param name="subject">Publish subject being authorized for the client.</param>
|
||||
public bool IsPublishAllowed(string subject)
|
||||
{
|
||||
if (_publish == null)
|
||||
@@ -56,6 +63,9 @@ public sealed class ClientPermissions : IDisposable
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// <summary>Determines whether subscribing to the given subject/queue is permitted.</summary>
|
||||
/// <param name="subject">Subscription subject being authorized.</param>
|
||||
/// <param name="queue">Optional queue group name for queue-subscription checks.</param>
|
||||
public bool IsSubscribeAllowed(string subject, string? queue = null)
|
||||
{
|
||||
if (_subscribe == null)
|
||||
@@ -67,6 +77,8 @@ public sealed class ClientPermissions : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Determines whether delivering a message on the subject is permitted.</summary>
|
||||
/// <param name="subject">Delivery subject evaluated against deny rules.</param>
|
||||
public bool IsDeliveryAllowed(string subject)
|
||||
{
|
||||
if (_subscribe == null)
|
||||
@@ -74,6 +86,7 @@ public sealed class ClientPermissions : IDisposable
|
||||
return _subscribe.IsDeliveryAllowed(subject);
|
||||
}
|
||||
|
||||
/// <summary>Disposes permission resources used by this evaluator.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_publish?.Dispose();
|
||||
@@ -92,6 +105,10 @@ public sealed class PermissionSet : IDisposable
|
||||
_deny = deny;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds allow/deny sublists from a subject-permission definition.
|
||||
/// </summary>
|
||||
/// <param name="permission">Allow/deny subject rules.</param>
|
||||
public static PermissionSet? Build(SubjectPermission? permission)
|
||||
{
|
||||
if (permission == null)
|
||||
@@ -123,6 +140,8 @@ public sealed class PermissionSet : IDisposable
|
||||
return new PermissionSet(allow, deny);
|
||||
}
|
||||
|
||||
/// <summary>Checks whether a subject passes allow/deny evaluation.</summary>
|
||||
/// <param name="subject">Subject candidate to evaluate against allow and deny lists.</param>
|
||||
public bool IsAllowed(string subject)
|
||||
{
|
||||
bool allowed = true;
|
||||
@@ -142,6 +161,8 @@ public sealed class PermissionSet : IDisposable
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// <summary>Checks whether a subject is explicitly denied.</summary>
|
||||
/// <param name="subject">Subject candidate evaluated against deny entries.</param>
|
||||
public bool IsDenied(string subject)
|
||||
{
|
||||
if (_deny == null) return false;
|
||||
@@ -149,6 +170,8 @@ public sealed class PermissionSet : IDisposable
|
||||
return result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>Checks delivery permission using deny-list semantics.</summary>
|
||||
/// <param name="subject">Subject being delivered to a subscriber.</param>
|
||||
public bool IsDeliveryAllowed(string subject)
|
||||
{
|
||||
if (_deny == null)
|
||||
@@ -157,6 +180,7 @@ public sealed class PermissionSet : IDisposable
|
||||
return result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
|
||||
}
|
||||
|
||||
/// <summary>Disposes internal allow/deny sublists.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_allow?.Dispose();
|
||||
|
||||
@@ -9,12 +9,26 @@ public sealed class ExternalAuthCalloutAuthenticator : IAuthenticator
|
||||
private readonly IExternalAuthClient _client;
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an authenticator that delegates user validation to the external auth callout subject.
|
||||
/// This mirrors the NATS external authorization flow used for centralized policy decisions.
|
||||
/// </summary>
|
||||
/// <param name="client">Client used to publish authorization requests and receive decisions.</param>
|
||||
/// <param name="timeout">Maximum time to wait for an authorization decision.</param>
|
||||
public ExternalAuthCalloutAuthenticator(IExternalAuthClient client, TimeSpan timeout)
|
||||
{
|
||||
_client = client;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a client by calling the external authorization service and mapping the decision
|
||||
/// into a local identity/account context for the accepted connection.
|
||||
/// </summary>
|
||||
/// <param name="context">Connection authentication inputs received from the CONNECT payload.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="AuthResult"/> when the callout allows the connection; otherwise <see langword="null"/>.
|
||||
/// </returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_timeout);
|
||||
|
||||
@@ -6,13 +6,28 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public interface IAuthenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to authenticate a client connection.
|
||||
/// </summary>
|
||||
/// <param name="context">Authentication context containing credentials and transport metadata.</param>
|
||||
AuthResult? Authenticate(ClientAuthContext context);
|
||||
}
|
||||
|
||||
public sealed class ClientAuthContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets CONNECT options and credential fields supplied by the client.
|
||||
/// </summary>
|
||||
public required ClientOptions Opts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets server-issued nonce bytes used for signature-based auth flows.
|
||||
/// </summary>
|
||||
public required byte[] Nonce { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the client TLS certificate presented during handshake, when available.
|
||||
/// </summary>
|
||||
public X509Certificate2? ClientCertificate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -99,9 +99,17 @@ public sealed class AccountLimits
|
||||
|
||||
public sealed class AccountJetStreamLimits
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of streams the account can create in JetStream.
|
||||
/// This limit protects cluster resources in multi-tenant deployments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("max_streams")]
|
||||
public int MaxStreams { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional JetStream service tier label assigned to the account (for example, dev or prod).
|
||||
/// Tier is used for policy and placement decisions in operator-managed environments.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tier")]
|
||||
public string? Tier { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ public interface IAccountResolver
|
||||
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
|
||||
/// the NKey is not known to this resolver.
|
||||
/// </summary>
|
||||
/// <param name="accountNkey">Account public NKey used as resolver lookup key.</param>
|
||||
Task<string?> FetchAsync(string accountNkey);
|
||||
|
||||
/// <summary>
|
||||
/// Stores (or replaces) the JWT for the given account NKey. Callers that
|
||||
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
|
||||
/// </summary>
|
||||
/// <param name="accountNkey">Account public NKey used as resolver storage key.</param>
|
||||
/// <param name="jwt">Account JWT content associated with the key.</param>
|
||||
Task StoreAsync(string accountNkey, string jwt);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -15,6 +15,14 @@ internal static class JwtConnectionTypes
|
||||
Standard, Websocket, Leafnode, LeafnodeWs, Mqtt, MqttWs, InProcess,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Converts raw JWT allowed connection type values into normalized server constants
|
||||
/// and tracks whether any unknown connection types were supplied by policy.
|
||||
/// </summary>
|
||||
/// <param name="values">Allowed connection type values from user JWT claims.</param>
|
||||
/// <returns>
|
||||
/// A set of valid normalized types and a flag indicating whether unknown values were present.
|
||||
/// </returns>
|
||||
public static (HashSet<string> Valid, bool HasUnknown) Convert(IEnumerable<string>? values)
|
||||
{
|
||||
var valid = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class NatsJwt
|
||||
/// <summary>
|
||||
/// Returns true if the string appears to be a JWT (starts with "eyJ").
|
||||
/// </summary>
|
||||
/// <param name="token">Token string to inspect.</param>
|
||||
public static bool IsJwt(string token)
|
||||
{
|
||||
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
|
||||
@@ -28,6 +29,7 @@ public static class NatsJwt
|
||||
/// Decodes a JWT token into its constituent parts without verifying the signature.
|
||||
/// Returns null if the token is structurally invalid.
|
||||
/// </summary>
|
||||
/// <param name="token">JWT string in header.payload.signature format.</param>
|
||||
public static JwtToken? Decode(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -68,6 +70,7 @@ public static class NatsJwt
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
/// <param name="token">JWT string to decode.</param>
|
||||
public static UserClaims? DecodeUserClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
@@ -88,6 +91,7 @@ public static class NatsJwt
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
/// <param name="token">JWT string to decode.</param>
|
||||
public static AccountClaims? DecodeAccountClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
@@ -107,6 +111,8 @@ public static class NatsJwt
|
||||
/// <summary>
|
||||
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
|
||||
/// </summary>
|
||||
/// <param name="token">JWT string to verify.</param>
|
||||
/// <param name="publicNkey">Expected signer public NKey.</param>
|
||||
public static bool Verify(string token, string publicNkey)
|
||||
{
|
||||
try
|
||||
@@ -129,6 +135,9 @@ public static class NatsJwt
|
||||
/// Verifies a nonce signature against the given NKey public key.
|
||||
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
|
||||
/// </summary>
|
||||
/// <param name="nonce">Raw nonce bytes originally issued by the server.</param>
|
||||
/// <param name="signature">Signature string provided by the client.</param>
|
||||
/// <param name="publicNkey">Client public NKey used for verification.</param>
|
||||
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
||||
{
|
||||
try
|
||||
@@ -150,6 +159,7 @@ public static class NatsJwt
|
||||
/// Decodes a base64url-encoded byte array.
|
||||
/// Replaces URL-safe characters and adds padding as needed.
|
||||
/// </summary>
|
||||
/// <param name="input">Base64url-encoded string.</param>
|
||||
internal static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
@@ -213,9 +223,11 @@ public sealed class JwtToken
|
||||
/// </summary>
|
||||
public sealed class JwtHeader
|
||||
{
|
||||
/// <summary>JWT signing algorithm identifier (typically <c>ed25519-nkey</c> for NATS).</summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
/// <summary>JWT type marker (typically <c>JWT</c>).</summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("typ")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ public static partial class PermissionTemplates
|
||||
/// Returns an empty list if any template resolves to no values (tag not found).
|
||||
/// Returns a single-element list containing the original pattern if no templates are present.
|
||||
/// </summary>
|
||||
/// <param name="pattern">Template subject pattern to expand.</param>
|
||||
/// <param name="name">User display name from JWT claims.</param>
|
||||
/// <param name="subject">User public NKey subject from JWT claims.</param>
|
||||
/// <param name="accountName">Account display name from account JWT.</param>
|
||||
/// <param name="accountSubject">Account public NKey subject from account JWT.</param>
|
||||
/// <param name="userTags">User tag set in <c>key:value</c> form.</param>
|
||||
/// <param name="accountTags">Account tag set in <c>key:value</c> form.</param>
|
||||
public static List<string> Expand(
|
||||
string pattern,
|
||||
string name, string subject,
|
||||
@@ -71,6 +78,13 @@ public static partial class PermissionTemplates
|
||||
/// Expands all patterns in a permission list, flattening multi-value expansions
|
||||
/// into the result. Patterns that resolve to no values are omitted entirely.
|
||||
/// </summary>
|
||||
/// <param name="patterns">Permission subject patterns to expand.</param>
|
||||
/// <param name="name">User display name from JWT claims.</param>
|
||||
/// <param name="subject">User public NKey subject from JWT claims.</param>
|
||||
/// <param name="accountName">Account display name from account JWT.</param>
|
||||
/// <param name="accountSubject">Account public NKey subject from account JWT.</param>
|
||||
/// <param name="userTags">User tag set in <c>key:value</c> form.</param>
|
||||
/// <param name="accountTags">Account tag set in <c>key:value</c> form.</param>
|
||||
public static List<string> ExpandAll(
|
||||
IEnumerable<string> patterns,
|
||||
string name, string subject,
|
||||
|
||||
@@ -12,12 +12,26 @@ public sealed class JwtAuthenticator : IAuthenticator
|
||||
private readonly string[] _trustedKeys;
|
||||
private readonly IAccountResolver _resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a JWT authenticator that trusts the provided operator keys and resolves account JWTs
|
||||
/// from the configured resolver source.
|
||||
/// </summary>
|
||||
/// <param name="trustedKeys">Trusted operator/signing keys allowed to issue account JWTs.</param>
|
||||
/// <param name="resolver">Resolver used to fetch account claims by account public key.</param>
|
||||
public JwtAuthenticator(string[] trustedKeys, IAccountResolver resolver)
|
||||
{
|
||||
_trustedKeys = trustedKeys;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a client using NATS JWT flow: decode user claims, resolve account claims,
|
||||
/// verify trust/signatures/revocations, and derive connection permissions and limits.
|
||||
/// </summary>
|
||||
/// <param name="context">CONNECT options and nonce/signature context for the client.</param>
|
||||
/// <returns>
|
||||
/// Populated authentication result when JWT policy allows the connection; otherwise <see langword="null"/>.
|
||||
/// </returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var jwt = context.Opts.JWT;
|
||||
|
||||
@@ -18,6 +18,12 @@ public sealed class NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers) : IAuthen
|
||||
u => u,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a client by verifying its nonce signature with the presented NKey public key
|
||||
/// and returning the mapped account and permission context for that key.
|
||||
/// </summary>
|
||||
/// <param name="context">CONNECT payload plus server nonce used for signature verification.</param>
|
||||
/// <returns><see cref="AuthResult"/> for a valid NKey user; otherwise <see langword="null"/>.</returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var clientNkey = context.Opts.Nkey;
|
||||
|
||||
@@ -2,11 +2,38 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class NKeyUser
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the public NKey used for challenge-signature authentication.
|
||||
/// </summary>
|
||||
public required string Nkey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets publish/subscribe permission rules assigned to this NKey identity.
|
||||
/// </summary>
|
||||
public Permissions? Permissions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the account this NKey user is bound to.
|
||||
/// </summary>
|
||||
public string? Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional signing key used for delegated user JWT issuance.
|
||||
/// </summary>
|
||||
public string? SigningKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issuance timestamp associated with this identity claim.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Issued { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional connection-type restrictions for this identity.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this identity must be presented through proxy auth.
|
||||
/// </summary>
|
||||
public bool ProxyRequired { get; init; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ public sealed class PermissionLruCache
|
||||
private long _generation;
|
||||
private long _cacheGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fixed-capacity permission LRU cache.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Maximum number of cached permission decisions.</param>
|
||||
public PermissionLruCache(int capacity = 128)
|
||||
{
|
||||
_capacity = capacity;
|
||||
@@ -51,6 +55,8 @@ public sealed class PermissionLruCache
|
||||
// ── PUB API (backward-compatible) ────────────────────────────────────────
|
||||
|
||||
/// <summary>Looks up a PUB permission for <paramref name="key"/>.</summary>
|
||||
/// <param name="key">Publish subject cache key.</param>
|
||||
/// <param name="value">Cached allow/deny decision when present.</param>
|
||||
public bool TryGet(string key, out bool value)
|
||||
{
|
||||
var internalKey = "P:" + key;
|
||||
@@ -71,6 +77,8 @@ public sealed class PermissionLruCache
|
||||
}
|
||||
|
||||
/// <summary>Stores a PUB permission for <paramref name="key"/>.</summary>
|
||||
/// <param name="key">Publish subject cache key.</param>
|
||||
/// <param name="value">Allow/deny decision to cache.</param>
|
||||
public void Set(string key, bool value)
|
||||
{
|
||||
var internalKey = "P:" + key;
|
||||
@@ -84,6 +92,8 @@ public sealed class PermissionLruCache
|
||||
// ── SUB API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Looks up a SUB permission for <paramref name="subject"/>.</summary>
|
||||
/// <param name="subject">Subscribe subject cache key.</param>
|
||||
/// <param name="value">Cached allow/deny decision when present.</param>
|
||||
public bool TryGetSub(string subject, out bool value)
|
||||
{
|
||||
var internalKey = "S:" + subject;
|
||||
@@ -104,6 +114,8 @@ public sealed class PermissionLruCache
|
||||
}
|
||||
|
||||
/// <summary>Stores a SUB permission for <paramref name="subject"/>.</summary>
|
||||
/// <param name="subject">Subscribe subject cache key.</param>
|
||||
/// <param name="allowed">Allow/deny decision to cache.</param>
|
||||
public void SetSub(string subject, bool allowed)
|
||||
{
|
||||
var internalKey = "S:" + subject;
|
||||
@@ -116,6 +128,7 @@ public sealed class PermissionLruCache
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Current number of cached entries.</summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
|
||||
@@ -2,19 +2,44 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class Permissions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets publish-side allow/deny subject rules.
|
||||
/// </summary>
|
||||
public SubjectPermission? Publish { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets subscribe-side allow/deny subject rules.
|
||||
/// </summary>
|
||||
public SubjectPermission? Subscribe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets dynamic reply-publish permissions granted to request responders.
|
||||
/// </summary>
|
||||
public ResponsePermission? Response { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SubjectPermission
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets subject patterns explicitly permitted for the operation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Allow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets subject patterns explicitly denied for the operation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Deny { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ResponsePermission
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the maximum number of response messages allowed on auto-generated reply subjects.
|
||||
/// </summary>
|
||||
public int MaxMsgs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expiration window for temporary response permissions.
|
||||
/// </summary>
|
||||
public TimeSpan Expires { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class ProxyAuthenticator(ProxyAuthOptions options) : IAuthenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates a client from a trusted proxy identity prefix and maps it to the configured account.
|
||||
/// This supports edge proxies that perform upstream auth and pass a canonical user principal.
|
||||
/// </summary>
|
||||
/// <param name="context">Client credentials and connection metadata from CONNECT.</param>
|
||||
/// <returns><see cref="AuthResult"/> when proxy-auth rules match; otherwise <see langword="null"/>.</returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
|
||||
@@ -11,17 +11,29 @@ public sealed class ResponseTracker
|
||||
private readonly Dictionary<string, (DateTime RegisteredAt, int Count)> _replies = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tracker for temporary response-subject permissions.
|
||||
/// </summary>
|
||||
/// <param name="maxMsgs">Maximum allowed publishes per reply subject (0 for unlimited).</param>
|
||||
/// <param name="expires">TTL for each registered reply subject (<see cref="TimeSpan.Zero"/> for no TTL).</param>
|
||||
public ResponseTracker(int maxMsgs, TimeSpan expires)
|
||||
{
|
||||
_maxMsgs = maxMsgs;
|
||||
_expires = expires;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of currently tracked reply subjects.
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) return _replies.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a reply subject for temporary publish authorization.
|
||||
/// </summary>
|
||||
/// <param name="replySubject">Reply subject allowed for responder publishes.</param>
|
||||
public void RegisterReply(string replySubject)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -30,6 +42,10 @@ public sealed class ResponseTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a publish to the reply subject is currently allowed.
|
||||
/// </summary>
|
||||
/// <param name="subject">Reply subject being authorized.</param>
|
||||
public bool IsReplyAllowed(string subject)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -55,6 +71,9 @@ public sealed class ResponseTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes expired or exhausted reply permissions from the tracker.
|
||||
/// </summary>
|
||||
public void Prune()
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -11,12 +11,17 @@ public sealed class ServiceLatencyTracker
|
||||
private readonly int _maxSamples;
|
||||
private long _totalRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a latency tracker with a bounded in-memory sample window.
|
||||
/// </summary>
|
||||
/// <param name="maxSamples">Maximum number of latency samples retained for percentile calculations.</param>
|
||||
public ServiceLatencyTracker(int maxSamples = 10000)
|
||||
{
|
||||
_maxSamples = maxSamples;
|
||||
}
|
||||
|
||||
/// <summary>Records a latency sample in milliseconds.</summary>
|
||||
/// <param name="latencyMs">Observed end-to-end service latency in milliseconds.</param>
|
||||
public void RecordLatency(double latencyMs)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -28,11 +33,15 @@ public sealed class ServiceLatencyTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the 50th percentile (median) latency in milliseconds.</summary>
|
||||
public double GetP50() => GetPercentile(0.50);
|
||||
/// <summary>Returns the 90th percentile latency in milliseconds.</summary>
|
||||
public double GetP90() => GetPercentile(0.90);
|
||||
/// <summary>Returns the 99th percentile latency in milliseconds.</summary>
|
||||
public double GetP99() => GetPercentile(0.99);
|
||||
|
||||
/// <summary>Returns the value at the given percentile (0.0–1.0) over recorded samples.</summary>
|
||||
/// <param name="percentile">Percentile fraction between 0.0 and 1.0.</param>
|
||||
public double GetPercentile(double percentile)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -61,16 +70,19 @@ public sealed class ServiceLatencyTracker
|
||||
return sum / samples.Count;
|
||||
}
|
||||
|
||||
/// <summary>Total number of latency observations recorded.</summary>
|
||||
public long TotalRequests
|
||||
{
|
||||
get { lock (_lock) return _totalRequests; }
|
||||
}
|
||||
|
||||
/// <summary>Arithmetic mean latency across currently retained samples.</summary>
|
||||
public double AverageLatencyMs
|
||||
{
|
||||
get { lock (_lock) return ComputeAverage(_samples); }
|
||||
}
|
||||
|
||||
/// <summary>Minimum latency among currently retained samples.</summary>
|
||||
public double MinLatencyMs
|
||||
{
|
||||
get
|
||||
@@ -80,6 +92,7 @@ public sealed class ServiceLatencyTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maximum latency among currently retained samples.</summary>
|
||||
public double MaxLatencyMs
|
||||
{
|
||||
get
|
||||
@@ -89,6 +102,7 @@ public sealed class ServiceLatencyTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Number of samples currently retained in memory.</summary>
|
||||
public int SampleCount
|
||||
{
|
||||
get { lock (_lock) return _samples.Count; }
|
||||
|
||||
@@ -14,12 +14,22 @@ public sealed class SimpleUserPasswordAuthenticator : IAuthenticator
|
||||
private readonly byte[] _expectedUsername;
|
||||
private readonly string _serverPassword;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an authenticator for a single configured user credential pair from server options.
|
||||
/// </summary>
|
||||
/// <param name="username">Expected username for incoming client connections.</param>
|
||||
/// <param name="password">Expected password (plain or bcrypt hash) for that username.</param>
|
||||
public SimpleUserPasswordAuthenticator(string username, string password)
|
||||
{
|
||||
_expectedUsername = Encoding.UTF8.GetBytes(username);
|
||||
_serverPassword = password;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the configured single user using constant-time comparisons and optional bcrypt verification.
|
||||
/// </summary>
|
||||
/// <param name="context">Client-provided username/password from CONNECT.</param>
|
||||
/// <returns><see cref="AuthResult"/> on successful validation; otherwise <see langword="null"/>.</returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var clientUsername = context.Opts.Username;
|
||||
|
||||
@@ -11,6 +11,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
private readonly Dictionary<string, User> _usersByDn;
|
||||
private readonly Dictionary<string, User> _usersByCn;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TLS-map authenticator using configured users keyed by DN/CN-style identities.
|
||||
/// </summary>
|
||||
/// <param name="users">Configured users used for DN/CN lookup matches.</param>
|
||||
public TlsMapAuthenticator(IReadOnlyList<User> users)
|
||||
{
|
||||
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -22,6 +26,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a client by matching certificate subject/SAN data to configured users.
|
||||
/// </summary>
|
||||
/// <param name="context">Authentication context containing the client TLS certificate.</param>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var cert = context.ClientCertificate;
|
||||
@@ -65,6 +73,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts domain-component RDN elements from a distinguished name.
|
||||
/// </summary>
|
||||
/// <param name="dn">Distinguished name to inspect for <c>DC=</c> elements.</param>
|
||||
internal static string GetTlsAuthDcs(X500DistinguishedName dn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dn.Name))
|
||||
@@ -82,6 +94,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
return string.Join(",", dcs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a DNS alternative-name value into normalized lowercase labels.
|
||||
/// </summary>
|
||||
/// <param name="dnsAltName">DNS SAN value from a certificate.</param>
|
||||
internal static string[] DnsAltNameLabels(string dnsAltName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dnsAltName))
|
||||
@@ -90,6 +106,11 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
return dnsAltName.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether SAN DNS labels match any URL host in the provided list.
|
||||
/// </summary>
|
||||
/// <param name="dnsAltNameLabels">Normalized SAN label sequence (supports wildcard first label).</param>
|
||||
/// <param name="urls">Candidate URLs whose hosts are compared against SAN labels.</param>
|
||||
internal static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
|
||||
{
|
||||
foreach (var url in urls)
|
||||
|
||||
@@ -7,11 +7,20 @@ public sealed class TokenAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly byte[] _expectedToken;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a token authenticator for deployments that use shared bearer tokens.
|
||||
/// </summary>
|
||||
/// <param name="token">Server-configured token value clients must present in CONNECT.</param>
|
||||
public TokenAuthenticator(string token)
|
||||
{
|
||||
_expectedToken = Encoding.UTF8.GetBytes(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the client using constant-time token comparison to avoid timing leakage.
|
||||
/// </summary>
|
||||
/// <param name="context">Client connection options containing the presented token.</param>
|
||||
/// <returns><see cref="AuthResult"/> for a matching token; otherwise <see langword="null"/>.</returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var clientToken = context.Opts.Token;
|
||||
|
||||
@@ -2,11 +2,38 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class User
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the username used for CONNECT credential authentication.
|
||||
/// </summary>
|
||||
public required string Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the password associated with <see cref="Username"/>.
|
||||
/// </summary>
|
||||
public required string Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets publish/subscribe permission rules assigned to this user.
|
||||
/// </summary>
|
||||
public Permissions? Permissions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the account this user is bound to for subject and subscription isolation.
|
||||
/// </summary>
|
||||
public string? Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional cutoff timestamp after which new connections are rejected.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ConnectionDeadline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional connection-type restrictions (client, route, gateway, leaf, and so on).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this identity must authenticate through trusted proxy headers.
|
||||
/// </summary>
|
||||
public bool ProxyRequired { get; init; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ public sealed class UserPasswordAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly Dictionary<string, User> _users;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an authenticator for a configured user set and builds a fast lookup by username.
|
||||
/// </summary>
|
||||
/// <param name="users">Configured users with account mappings and permission scopes.</param>
|
||||
public UserPasswordAuthenticator(IEnumerable<User> users)
|
||||
{
|
||||
_users = new Dictionary<string, User>(StringComparer.Ordinal);
|
||||
@@ -20,6 +24,11 @@ public sealed class UserPasswordAuthenticator : IAuthenticator
|
||||
_users[user.Username] = user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a username/password client and returns account, permissions, and connection expiry metadata.
|
||||
/// </summary>
|
||||
/// <param name="context">Client CONNECT credentials.</param>
|
||||
/// <returns><see cref="AuthResult"/> when credentials match; otherwise <see langword="null"/>.</returns>
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var username = context.Opts.Username;
|
||||
|
||||
@@ -28,6 +28,11 @@ public enum ClientClosedReason
|
||||
|
||||
public static class ClientClosedReasonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an internal close reason enum into the human-readable text exposed by monitoring endpoints.
|
||||
/// </summary>
|
||||
/// <param name="reason">Internal close classification captured at disconnect time.</param>
|
||||
/// <returns>Display string used in `/connz` and related operational diagnostics.</returns>
|
||||
public static string ToReasonString(this ClientClosedReason reason) => reason switch
|
||||
{
|
||||
ClientClosedReason.None => "",
|
||||
|
||||
@@ -25,16 +25,28 @@ public sealed class ClientFlagHolder
|
||||
{
|
||||
private int _flags;
|
||||
|
||||
/// <summary>
|
||||
/// Atomically sets the specified client state flag.
|
||||
/// </summary>
|
||||
/// <param name="flag">Flag to set.</param>
|
||||
public void SetFlag(ClientFlags flag)
|
||||
{
|
||||
Interlocked.Or(ref _flags, (int)flag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically clears the specified client state flag.
|
||||
/// </summary>
|
||||
/// <param name="flag">Flag to clear.</param>
|
||||
public void ClearFlag(ClientFlags flag)
|
||||
{
|
||||
Interlocked.And(ref _flags, ~(int)flag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the specified client state flag is currently set.
|
||||
/// </summary>
|
||||
/// <param name="flag">Flag to test.</param>
|
||||
public bool HasFlag(ClientFlags flag)
|
||||
{
|
||||
return (Volatile.Read(ref _flags) & (int)flag) != 0;
|
||||
|
||||
@@ -17,6 +17,12 @@ public enum ClientKind
|
||||
|
||||
public static class ClientKindExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether a client kind represents internal server infrastructure traffic
|
||||
/// rather than an external end-user connection.
|
||||
/// </summary>
|
||||
/// <param name="kind">Connection kind being evaluated.</param>
|
||||
/// <returns><see langword="true"/> for internal kinds such as system and JetStream.</returns>
|
||||
public static bool IsInternal(this ClientKind kind) =>
|
||||
kind is ClientKind.System or ClientKind.JetStream or ClientKind.Account;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ public sealed class ClientTraceInfo
|
||||
/// Records a message delivery trace if tracing is enabled.
|
||||
/// Go reference: server/client.go — traceMsg / TraceMsgDelivery.
|
||||
/// </summary>
|
||||
/// <param name="subject">Published subject that triggered this delivery path.</param>
|
||||
/// <param name="destination">Destination descriptor such as a client, queue group, or route hop.</param>
|
||||
/// <param name="payloadSize">Payload size in bytes used for throughput and fan-out diagnostics.</param>
|
||||
public void TraceMsgDelivery(string subject, string destination, int payloadSize)
|
||||
{
|
||||
if (!TraceEnabled) return;
|
||||
@@ -50,6 +53,8 @@ public sealed class ClientTraceInfo
|
||||
/// subscriptions on the same client.
|
||||
/// Go reference: server/client.go — c.echo check in deliverMsg.
|
||||
/// </summary>
|
||||
/// <param name="publisherClientId">Client identifier that originated the publish.</param>
|
||||
/// <param name="subscriberClientId">Client identifier for the subscription currently being evaluated.</param>
|
||||
public bool ShouldEcho(string publisherClientId, string subscriberClientId)
|
||||
{
|
||||
if (EchoEnabled) return true;
|
||||
@@ -76,8 +81,23 @@ public sealed class ClientTraceInfo
|
||||
|
||||
public sealed record TraceRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the routed subject for the traced delivery event.
|
||||
/// </summary>
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved destination where the server sent the message.
|
||||
/// </summary>
|
||||
public string Destination { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the payload size in bytes for the traced message.
|
||||
/// </summary>
|
||||
public int PayloadSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UTC timestamp captured when the trace event was recorded.
|
||||
/// </summary>
|
||||
public DateTime TimestampUtc { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster listener and route fan-out settings used for server-to-server mesh links.
|
||||
/// </summary>
|
||||
public sealed class ClusterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the local cluster name advertised during route handshakes.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the network interface used to accept inbound route connections.
|
||||
/// </summary>
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TCP port for the cluster route listener.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 6222;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of parallel route connections maintained per remote server.
|
||||
/// </summary>
|
||||
public int PoolSize { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configured outbound route URLs used to join peer servers.
|
||||
/// </summary>
|
||||
public List<string> Routes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets account names that should use dedicated route handling.
|
||||
/// </summary>
|
||||
public List<string> Accounts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets compression behavior for inter-server route traffic.
|
||||
/// </summary>
|
||||
public RouteCompression Compression { get; set; } = RouteCompression.None;
|
||||
|
||||
// Go: opts.go — cluster write_deadline
|
||||
/// <summary>
|
||||
/// Gets or sets the write deadline enforced for route protocol socket operations.
|
||||
/// </summary>
|
||||
public TimeSpan WriteDeadline { get; set; }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class ConfigProcessor
|
||||
/// <summary>
|
||||
/// Parses a configuration file and returns the populated options.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Absolute or relative path to the NATS configuration file to load.</param>
|
||||
public static NatsOptions ProcessConfigFile(string filePath)
|
||||
{
|
||||
var config = NatsConfParser.ParseFile(filePath);
|
||||
@@ -30,6 +31,7 @@ public static class ConfigProcessor
|
||||
/// <summary>
|
||||
/// Parses configuration text (not from a file) and returns the populated options.
|
||||
/// </summary>
|
||||
/// <param name="configText">Raw configuration text in NATS server config format.</param>
|
||||
public static NatsOptions ProcessConfig(string configText)
|
||||
{
|
||||
var config = NatsConfParser.Parse(configText);
|
||||
@@ -42,6 +44,8 @@ public static class ConfigProcessor
|
||||
/// Applies a parsed configuration dictionary to existing options.
|
||||
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
|
||||
/// </summary>
|
||||
/// <param name="config">Parsed config tree keyed by top-level field names.</param>
|
||||
/// <param name="opts">Options instance that receives normalized values from the parsed config.</param>
|
||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
@@ -423,6 +427,7 @@ public static class ConfigProcessor
|
||||
/// <item>A number (long/double) treated as seconds</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="value">Raw duration token from configuration (string or numeric seconds).</param>
|
||||
internal static TimeSpan ParseDuration(object? value)
|
||||
{
|
||||
return value switch
|
||||
@@ -1877,7 +1882,14 @@ public static class ConfigProcessor
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
|
||||
: Exception(message)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of blocking configuration errors that prevented startup.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
|
||||
/// <summary>
|
||||
/// Gets non-fatal configuration warnings collected during processing.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings => warnings ?? [];
|
||||
}
|
||||
|
||||
@@ -1887,6 +1899,9 @@ public sealed class ConfigProcessorException(string message, List<string> errors
|
||||
/// </summary>
|
||||
public class ConfigWarningException(string message, string? source = null) : Exception(message)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the location within the source config where this warning originated, when available.
|
||||
/// </summary>
|
||||
public string? SourceLocation { get; } = source;
|
||||
}
|
||||
|
||||
@@ -1897,5 +1912,8 @@ public class ConfigWarningException(string message, string? source = null) : Exc
|
||||
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
|
||||
: ConfigWarningException($"unknown field {field}", source)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unknown top-level or nested field name encountered in the configuration file.
|
||||
/// </summary>
|
||||
public string Field { get; } = field;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ public static class ConfigReloader
|
||||
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change
|
||||
/// is tagged with the appropriate category flags.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options before reload.</param>
|
||||
/// <param name="newOpts">Newly parsed options from config plus CLI overrides.</param>
|
||||
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var changes = new List<IConfigChange>();
|
||||
@@ -135,6 +137,7 @@ public static class ConfigReloader
|
||||
/// Validates a list of config changes and returns error messages for any
|
||||
/// non-reloadable changes (properties that require a server restart).
|
||||
/// </summary>
|
||||
/// <param name="changes">Detected config differences to validate for reload safety.</param>
|
||||
public static List<string> Validate(List<IConfigChange> changes)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
@@ -154,6 +157,9 @@ public static class ConfigReloader
|
||||
/// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
|
||||
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
|
||||
/// </summary>
|
||||
/// <param name="fromConfig">Options parsed from config file to mutate with CLI overrides.</param>
|
||||
/// <param name="cliValues">CLI snapshot values captured at process startup.</param>
|
||||
/// <param name="cliFlags">Set of option names that were explicitly supplied via CLI.</param>
|
||||
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
|
||||
{
|
||||
foreach (var flag in cliFlags)
|
||||
@@ -337,6 +343,9 @@ public static class ConfigReloader
|
||||
/// flags indicating which subsystems need to be notified.
|
||||
/// Reference: Go server/reload.go — applyOptions.
|
||||
/// </summary>
|
||||
/// <param name="changes">Validated config changes to apply.</param>
|
||||
/// <param name="currentOpts">Current in-memory options instance.</param>
|
||||
/// <param name="newOpts">New options values produced by config parse and CLI merge.</param>
|
||||
public static ConfigApplyResult ApplyDiff(
|
||||
List<IConfigChange> changes,
|
||||
NatsOptions currentOpts,
|
||||
@@ -366,6 +375,12 @@ public static class ConfigReloader
|
||||
/// the SIGHUP handler) is responsible for applying the result to the running server.
|
||||
/// Reference: Go server/reload.go — Reload.
|
||||
/// </summary>
|
||||
/// <param name="configFile">Config file path to parse.</param>
|
||||
/// <param name="currentOpts">Current in-memory options to compare against.</param>
|
||||
/// <param name="currentDigest">Current file digest used to skip unchanged reloads.</param>
|
||||
/// <param name="cliSnapshot">Optional CLI snapshot whose overrides must win over config values.</param>
|
||||
/// <param name="cliFlags">CLI option names explicitly set by the operator.</param>
|
||||
/// <param name="ct">Cancellation token for the reload operation.</param>
|
||||
public static async Task<ConfigReloadResult> ReloadAsync(
|
||||
string configFile,
|
||||
NatsOptions currentOpts,
|
||||
@@ -403,6 +418,8 @@ public static class ConfigReloader
|
||||
/// a reload result indicating whether the change is valid.
|
||||
/// Go reference: server/reload.go — Reload with in-memory options comparison.
|
||||
/// </summary>
|
||||
/// <param name="original">Original options baseline.</param>
|
||||
/// <param name="updated">Updated options candidate.</param>
|
||||
public static Task<ReloadFromOptionsResult> ReloadFromOptionsAsync(NatsOptions original, NatsOptions updated)
|
||||
{
|
||||
var changes = Diff(original, updated);
|
||||
@@ -428,6 +445,8 @@ public static class ConfigReloader
|
||||
/// Callers use this to reconcile route/gateway/leaf connections after a hot reload.
|
||||
/// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static ClusterConfigChangeResult ApplyClusterConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new ClusterConfigChangeResult();
|
||||
@@ -471,6 +490,8 @@ public static class ConfigReloader
|
||||
/// Debug → "Debug", otherwise "Information" — matching Go's precedence.
|
||||
/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static LoggingChangeResult ApplyLoggingChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new LoggingChangeResult();
|
||||
@@ -598,6 +619,8 @@ public static class ConfigReloader
|
||||
/// re-evaluation of existing connections after a config reload.
|
||||
/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static AuthChangeResult PropagateAuthChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new AuthChangeResult();
|
||||
@@ -636,6 +659,8 @@ public static class ConfigReloader
|
||||
/// If changed, validates the new cert is loadable.
|
||||
/// Go reference: server/reload.go — tlsConfigReload.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static TlsReloadResult ReloadTlsCertificates(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new TlsReloadResult();
|
||||
@@ -672,6 +697,8 @@ public static class ConfigReloader
|
||||
/// existing connections keep their original certificate.
|
||||
/// Reference: golang/nats-server/server/reload.go — tlsOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="options">Current options containing certificate/key paths.</param>
|
||||
/// <param name="certProvider">Certificate provider to update in place.</param>
|
||||
public static bool ReloadTlsCertificate(
|
||||
NatsOptions options,
|
||||
TlsCertificateProvider? certProvider)
|
||||
@@ -696,6 +723,8 @@ public static class ConfigReloader
|
||||
/// hot reload without requiring a server restart.
|
||||
/// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply.
|
||||
/// </summary>
|
||||
/// <param name="oldOpts">Current in-memory options baseline.</param>
|
||||
/// <param name="newOpts">Newly parsed options candidate.</param>
|
||||
public static JetStreamConfigChangeResult ApplyJetStreamConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var result = new JetStreamConfigChangeResult();
|
||||
@@ -753,12 +782,39 @@ public readonly record struct ConfigApplyResult(
|
||||
/// </summary>
|
||||
public sealed class ConfigReloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether reload was skipped because the config digest did not change.
|
||||
/// </summary>
|
||||
public bool Unchanged { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets newly parsed options when a reload candidate was produced.
|
||||
/// </summary>
|
||||
public NatsOptions? NewOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest for the parsed config file.
|
||||
/// </summary>
|
||||
public string? NewDigest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the detected config changes for this reload attempt.
|
||||
/// </summary>
|
||||
public List<IConfigChange>? Changes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets validation errors detected while evaluating the reload.
|
||||
/// </summary>
|
||||
public List<string>? Errors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a config reload result payload.
|
||||
/// </summary>
|
||||
/// <param name="Unchanged">Whether reload was skipped because the config digest was unchanged.</param>
|
||||
/// <param name="NewOptions">Newly parsed options candidate for applying a reload.</param>
|
||||
/// <param name="NewDigest">Digest string of the candidate config content.</param>
|
||||
/// <param name="Changes">Detected option differences for this reload attempt.</param>
|
||||
/// <param name="Errors">Validation errors that block applying the reload.</param>
|
||||
public ConfigReloadResult(
|
||||
bool Unchanged,
|
||||
NatsOptions? NewOptions = null,
|
||||
@@ -773,6 +829,9 @@ public sealed class ConfigReloadResult
|
||||
this.Errors = Errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this reload result contains validation errors.
|
||||
/// </summary>
|
||||
public bool HasErrors => Errors is { Count: > 0 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,75 @@
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a gateway listener and outbound gateway connections to other clusters.
|
||||
/// </summary>
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Local gateway name advertised to remote clusters.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Interface or host name used by the gateway listener.
|
||||
/// </summary>
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// TCP port used by the gateway listener.
|
||||
/// </summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remote gateway URLs from configuration.
|
||||
/// </summary>
|
||||
public List<string> Remotes { get; set; } = [];
|
||||
|
||||
// Go: opts.go — gateway authorization fields
|
||||
/// <summary>
|
||||
/// Rejects inbound gateway connections from clusters that are not explicitly configured.
|
||||
/// </summary>
|
||||
public bool RejectUnknown { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Username for gateway authentication.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for gateway authentication.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication timeout, in seconds, for gateway handshakes.
|
||||
/// </summary>
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional advertise endpoint sent to remote clusters instead of bind host and port.
|
||||
/// </summary>
|
||||
public string? Advertise { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of outbound connection retries before giving up.
|
||||
/// </summary>
|
||||
public int ConnectRetries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables backoff between outbound gateway reconnect attempts.
|
||||
/// </summary>
|
||||
public bool ConnectBackoff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Write deadline applied to outbound gateway socket writes.
|
||||
/// </summary>
|
||||
public TimeSpan WriteDeadline { get; set; }
|
||||
|
||||
// Go: opts.go — gateways remotes list (RemoteGatewayOpts)
|
||||
/// <summary>
|
||||
/// Expanded remote gateway definitions with runtime metadata.
|
||||
/// </summary>
|
||||
public List<RemoteGatewayOptions> RemoteGateways { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -28,12 +80,39 @@ public sealed class RemoteGatewayOptions
|
||||
{
|
||||
private int _connAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Remote gateway cluster name.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized remote URLs for this gateway.
|
||||
/// </summary>
|
||||
public List<string> Urls { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that this remote was discovered implicitly rather than configured statically.
|
||||
/// </summary>
|
||||
public bool Implicit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current hash of the URL set used for change detection.
|
||||
/// </summary>
|
||||
public byte[]? Hash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous hash value retained across URL updates.
|
||||
/// </summary>
|
||||
public byte[]? OldHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TLS server name captured from a remote URL host.
|
||||
/// </summary>
|
||||
public string? TlsName { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether URL changes should be surfaced in gateway monitoring endpoints.
|
||||
/// </summary>
|
||||
public bool VarzUpdateUrls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -54,14 +133,30 @@ public sealed class RemoteGatewayOptions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments and returns the number of outbound connection attempts.
|
||||
/// </summary>
|
||||
public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current outbound connection attempt count.
|
||||
/// </summary>
|
||||
public int GetConnAttempts() => Volatile.Read(ref _connAttempts);
|
||||
|
||||
/// <summary>
|
||||
/// Resets outbound connection attempt tracking.
|
||||
/// </summary>
|
||||
public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this remote gateway entry is implicit.
|
||||
/// </summary>
|
||||
public bool IsImplicit() => Implicit;
|
||||
|
||||
/// <summary>
|
||||
/// Returns normalized remote URLs in randomized order for reconnect balancing.
|
||||
/// </summary>
|
||||
/// <param name="random">Optional random source used for URL shuffle order.</param>
|
||||
public List<Uri> GetUrls(Random? random = null)
|
||||
{
|
||||
var urls = new List<Uri>();
|
||||
@@ -81,6 +176,9 @@ public sealed class RemoteGatewayOptions
|
||||
return urls;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns normalized URL strings for diagnostics and monitor payloads.
|
||||
/// </summary>
|
||||
public List<string> GetUrlsAsStrings()
|
||||
{
|
||||
var result = new List<string>();
|
||||
@@ -89,6 +187,11 @@ public sealed class RemoteGatewayOptions
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the URL list with a deduplicated merge of configured and discovered remotes.
|
||||
/// </summary>
|
||||
/// <param name="configuredUrls">Static URLs from server configuration.</param>
|
||||
/// <param name="discoveredUrls">Dynamic URLs discovered from gossip or INFO updates.</param>
|
||||
public void UpdateUrls(IEnumerable<string> configuredUrls, IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
var merged = new List<string>();
|
||||
@@ -97,12 +200,20 @@ public sealed class RemoteGatewayOptions
|
||||
Urls = merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and stores TLS server name from a remote URL.
|
||||
/// </summary>
|
||||
/// <param name="url">Remote URL string.</param>
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryNormalizeRemoteUrl(url, out var uri))
|
||||
TlsName = uri.Host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds discovered URLs to the existing URL list after normalization and deduplication.
|
||||
/// </summary>
|
||||
/// <param name="discoveredUrls">Discovered remote URLs.</param>
|
||||
public void AddUrls(IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
AddUrlsInternal(Urls, discoveredUrls);
|
||||
|
||||
@@ -46,9 +46,28 @@ public sealed class ConfigChange(
|
||||
bool isTlsChange = false,
|
||||
bool isNonReloadable = false) : IConfigChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the changed option name.
|
||||
/// </summary>
|
||||
public string Name => name;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this change affects logging configuration.
|
||||
/// </summary>
|
||||
public bool IsLoggingChange => isLoggingChange;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this change affects authentication configuration.
|
||||
/// </summary>
|
||||
public bool IsAuthChange => isAuthChange;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this change affects TLS configuration.
|
||||
/// </summary>
|
||||
public bool IsTlsChange => isTlsChange;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this change cannot be applied without restart.
|
||||
/// </summary>
|
||||
public bool IsNonReloadable => isNonReloadable;
|
||||
}
|
||||
|
||||
@@ -65,12 +65,15 @@ public sealed class RemoteLeafOptions
|
||||
/// Sets reconnect/connect delay for this remote.
|
||||
/// Go reference: leafnode.go leafNodeCfg.setConnectDelay.
|
||||
/// </summary>
|
||||
/// <param name="delay">Delay before the next reconnect attempt to this remote leaf.</param>
|
||||
public void SetConnectDelay(TimeSpan delay) => _connectDelay = delay;
|
||||
|
||||
/// <summary>
|
||||
/// Starts or replaces the JetStream migration timer callback for this remote leaf.
|
||||
/// Go reference: leafnode.go leafNodeCfg.migrateTimer.
|
||||
/// </summary>
|
||||
/// <param name="callback">Callback invoked when migration retry timing elapses.</param>
|
||||
/// <param name="delay">Initial delay before invoking the migration callback.</param>
|
||||
public void StartMigrateTimer(TimerCallback callback, TimeSpan delay)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
@@ -93,6 +96,7 @@ public sealed class RemoteLeafOptions
|
||||
/// Saves TLS hostname from URL for future SNI usage.
|
||||
/// Go reference: leafnode.go leafNodeCfg.saveTLSHostname.
|
||||
/// </summary>
|
||||
/// <param name="url">Remote leaf URL that supplies the SNI host name.</param>
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryParseUrl(url, out var uri))
|
||||
@@ -103,6 +107,7 @@ public sealed class RemoteLeafOptions
|
||||
/// Saves username/password from URL user info for fallback auth.
|
||||
/// Go reference: leafnode.go leafNodeCfg.saveUserPassword.
|
||||
/// </summary>
|
||||
/// <param name="url">Remote leaf URL containing optional user info credentials.</param>
|
||||
public void SaveUserPassword(string url)
|
||||
{
|
||||
if (!TryParseUrl(url, out var uri) || string.IsNullOrEmpty(uri.UserInfo))
|
||||
@@ -124,18 +129,25 @@ public sealed class RemoteLeafOptions
|
||||
|
||||
public sealed class LeafNodeOptions
|
||||
{
|
||||
/// <summary>Host/IP address where the leaf listener accepts incoming leaf connections.</summary>
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
/// <summary>TCP port exposed for leaf node connections.</summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
// Auth for leaf listener
|
||||
/// <summary>Optional username required for inbound leaf authentication.</summary>
|
||||
public string? Username { get; set; }
|
||||
/// <summary>Optional password required for inbound leaf authentication.</summary>
|
||||
public string? Password { get; set; }
|
||||
/// <summary>Maximum seconds a leaf connection has to complete authentication.</summary>
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
// Advertise address
|
||||
/// <summary>Optional externally reachable leaf address advertised to peers.</summary>
|
||||
public string? Advertise { get; set; }
|
||||
|
||||
// Per-subsystem write deadline
|
||||
/// <summary>Write deadline applied to leaf network operations.</summary>
|
||||
public TimeSpan WriteDeadline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -156,9 +168,13 @@ public sealed class LeafNodeOptions
|
||||
/// </summary>
|
||||
public string? JetStreamDomain { get; set; }
|
||||
|
||||
/// <summary>Subjects that this leaf cannot export to the remote account.</summary>
|
||||
public List<string> DenyExports { get; set; } = [];
|
||||
/// <summary>Subjects that this leaf cannot import from the remote account.</summary>
|
||||
public List<string> DenyImports { get; set; } = [];
|
||||
/// <summary>Subjects explicitly exported from this leaf to connected remotes.</summary>
|
||||
public List<string> ExportSubjects { get; set; } = [];
|
||||
/// <summary>Subjects explicitly imported from remote leaves into this server.</summary>
|
||||
public List<string> ImportSubjects { get; set; } = [];
|
||||
|
||||
/// <summary>List of users for leaf listener authentication (from authorization.users).</summary>
|
||||
|
||||
@@ -63,6 +63,12 @@ public sealed class NatsConfLexer
|
||||
_ilstart = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tokenizes a NATS configuration document into lexical tokens consumed by the config parser.
|
||||
/// The lexer preserves Go-compatible token rules for production config parity.
|
||||
/// </summary>
|
||||
/// <param name="input">Raw configuration text in NATS conf syntax.</param>
|
||||
/// <returns>Ordered token stream including error tokens when malformed input is encountered.</returns>
|
||||
public static IReadOnlyList<Token> Tokenize(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class NatsConfParser
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration string into a dictionary.
|
||||
/// </summary>
|
||||
/// <param name="data">Raw configuration text.</param>
|
||||
public static Dictionary<string, object?> Parse(string data)
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
@@ -40,11 +41,13 @@ public static class NatsConfParser
|
||||
/// Pedantic compatibility API (Go: ParseWithChecks).
|
||||
/// Uses the same parser behavior as <see cref="Parse(string)"/>.
|
||||
/// </summary>
|
||||
/// <param name="data">Raw configuration text.</param>
|
||||
public static Dictionary<string, object?> ParseWithChecks(string data) => Parse(data);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the configuration file.</param>
|
||||
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
||||
ParseFile(filePath, includeDepth: 0);
|
||||
|
||||
@@ -52,6 +55,7 @@ public static class NatsConfParser
|
||||
/// Pedantic compatibility API (Go: ParseFileWithChecks).
|
||||
/// Uses the same parser behavior as <see cref="ParseFile(string)"/>.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the configuration file.</param>
|
||||
public static Dictionary<string, object?> ParseFileWithChecks(string filePath) => ParseFile(filePath);
|
||||
|
||||
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
||||
@@ -68,6 +72,7 @@ public static class NatsConfParser
|
||||
/// Parses a NATS configuration file and returns the parsed config plus a
|
||||
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the configuration file.</param>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
|
||||
{
|
||||
var rawBytes = File.ReadAllBytes(filePath);
|
||||
@@ -85,6 +90,7 @@ public static class NatsConfParser
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseFileWithChecksDigest).
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the configuration file.</param>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithChecksDigest(string filePath)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
@@ -204,13 +210,26 @@ public static class NatsConfParser
|
||||
// Pedantic-mode key token stack (Go parser field: ikeys).
|
||||
private readonly List<Token> _itemKeys = new(4);
|
||||
|
||||
/// <summary>Root parsed mapping for the current parser execution.</summary>
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Creates parser state for tokenized config input.
|
||||
/// </summary>
|
||||
/// <param name="tokens">Token stream from the config lexer.</param>
|
||||
/// <param name="baseDir">Base directory used to resolve include paths.</param>
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
||||
: this(tokens, baseDir, [], includeDepth: 0)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates parser state with explicit env-reference tracking and include depth.
|
||||
/// </summary>
|
||||
/// <param name="tokens">Token stream from the config lexer.</param>
|
||||
/// <param name="baseDir">Base directory used to resolve include paths.</param>
|
||||
/// <param name="envVarReferences">Shared environment-variable recursion guard set.</param>
|
||||
/// <param name="includeDepth">Current include nesting depth.</param>
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences, int includeDepth)
|
||||
{
|
||||
_tokens = tokens;
|
||||
@@ -219,6 +238,9 @@ public static class NatsConfParser
|
||||
_includeDepth = includeDepth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the parse loop and builds <see cref="Mapping"/>.
|
||||
/// </summary>
|
||||
public void Run()
|
||||
{
|
||||
PushContext(Mapping);
|
||||
|
||||
@@ -36,6 +36,13 @@ public sealed class PedanticToken
|
||||
private readonly bool _usedVariable;
|
||||
private readonly string _sourceFile;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a parser token wrapper that preserves resolved value and source metadata.
|
||||
/// </summary>
|
||||
/// <param name="item">Raw lexer token captured from the configuration source.</param>
|
||||
/// <param name="value">Optional parsed value override when token text has been normalized.</param>
|
||||
/// <param name="usedVariable">Indicates whether this token originated from variable substitution.</param>
|
||||
/// <param name="sourceFile">Source file path associated with this token, when available.</param>
|
||||
public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "")
|
||||
{
|
||||
_item = item;
|
||||
@@ -44,15 +51,33 @@ public sealed class PedanticToken
|
||||
_sourceFile = sourceFile ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the token value into JSON, matching Go parser diagnostics formatting.
|
||||
/// </summary>
|
||||
public string MarshalJson() => JsonSerializer.Serialize(Value());
|
||||
|
||||
/// <summary>
|
||||
/// Returns the resolved token value, or raw token text when no typed value is stored.
|
||||
/// </summary>
|
||||
public object? Value() => _value ?? _item.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the 1-based source line where the token was parsed.
|
||||
/// </summary>
|
||||
public int Line() => _item.Line;
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether variable interpolation contributed to this token.
|
||||
/// </summary>
|
||||
public bool IsUsedVariable() => _usedVariable;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the source file path associated with this token.
|
||||
/// </summary>
|
||||
public string SourceFile() => _sourceFile;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the 1-based character position of the token on its source line.
|
||||
/// </summary>
|
||||
public int Position() => _item.Position;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ public static class EventCompressor
|
||||
/// <summary>
|
||||
/// Compresses <paramref name="payload"/> using the requested <paramref name="compression"/>.
|
||||
/// </summary>
|
||||
/// <param name="payload">Uncompressed event payload bytes.</param>
|
||||
/// <param name="compression">Compression algorithm to apply for transport.</param>
|
||||
public static byte[] Compress(ReadOnlySpan<byte> payload, EventCompressionType compression)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
@@ -104,6 +106,8 @@ public static class EventCompressor
|
||||
/// <summary>
|
||||
/// Decompresses <paramref name="compressed"/> using the selected <paramref name="compression"/>.
|
||||
/// </summary>
|
||||
/// <param name="compressed">Compressed event payload bytes.</param>
|
||||
/// <param name="compression">Encoding that was used when the payload was produced.</param>
|
||||
public static byte[] Decompress(ReadOnlySpan<byte> compressed, EventCompressionType compression)
|
||||
{
|
||||
if (compressed.IsEmpty)
|
||||
@@ -150,6 +154,9 @@ public static class EventCompressor
|
||||
/// <summary>
|
||||
/// Compresses using <paramref name="compression"/> when payload size exceeds threshold.
|
||||
/// </summary>
|
||||
/// <param name="payload">Raw event payload that may be compressed.</param>
|
||||
/// <param name="compression">Preferred compression algorithm for eligible payloads.</param>
|
||||
/// <param name="thresholdBytes">Minimum payload size required before compression is attempted.</param>
|
||||
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
|
||||
ReadOnlySpan<byte> payload,
|
||||
EventCompressionType compression,
|
||||
@@ -189,6 +196,7 @@ public static class EventCompressor
|
||||
/// Parses an HTTP Accept-Encoding value into a supported compression type.
|
||||
/// Go reference: events.go getAcceptEncoding().
|
||||
/// </summary>
|
||||
/// <param name="acceptEncoding">Raw HTTP <c>Accept-Encoding</c> header value from the client.</param>
|
||||
public static EventCompressionType GetAcceptEncoding(string? acceptEncoding)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(acceptEncoding))
|
||||
|
||||
@@ -74,6 +74,13 @@ public static class EventSubjects
|
||||
/// Callback signature for system message handlers.
|
||||
/// Maps to Go's sysMsgHandler type in events.go:109.
|
||||
/// </summary>
|
||||
/// <param name="sub">Subscription metadata that matched the incoming system message.</param>
|
||||
/// <param name="client">Client connection context that delivered the message, when available.</param>
|
||||
/// <param name="account">Owning account context for account-scoped system events.</param>
|
||||
/// <param name="subject">System subject that triggered this callback.</param>
|
||||
/// <param name="reply">Reply inbox subject for request/reply system handlers.</param>
|
||||
/// <param name="headers">Optional message headers encoded by the publisher.</param>
|
||||
/// <param name="message">Raw system advisory or request payload bytes.</param>
|
||||
public delegate void SystemMessageHandler(
|
||||
Subscription? sub,
|
||||
INatsClient? client,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,39 @@ namespace NATS.Server.Events;
|
||||
/// </summary>
|
||||
public sealed class PublishMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets optional originating internal client context for this publish.
|
||||
/// </summary>
|
||||
public InternalClient? Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the destination subject for the internal publish.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional reply subject.
|
||||
/// </summary>
|
||||
public string? Reply { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional header bytes for HMSG-style delivery.
|
||||
/// </summary>
|
||||
public byte[]? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the payload object to serialize and publish.
|
||||
/// </summary>
|
||||
public object? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this event should be echoed back to the sender context.
|
||||
/// </summary>
|
||||
public bool Echo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this message is the final send-loop item before shutdown.
|
||||
/// </summary>
|
||||
public bool IsLast { get; init; }
|
||||
}
|
||||
|
||||
@@ -28,13 +55,44 @@ public sealed class PublishMessage
|
||||
/// </summary>
|
||||
public sealed class InternalSystemMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched internal subscription.
|
||||
/// </summary>
|
||||
public required Subscription? Sub { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal client delivering the message.
|
||||
/// </summary>
|
||||
public required INatsClient? Client { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the account context for this internal dispatch.
|
||||
/// </summary>
|
||||
public required Account? Account { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message subject.
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional reply subject.
|
||||
/// </summary>
|
||||
public required string? Reply { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets message header bytes.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets message payload bytes.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets callback invoked by the internal receive loop.
|
||||
/// </summary>
|
||||
public required SystemMessageHandler Callback { get; init; }
|
||||
}
|
||||
|
||||
@@ -113,8 +171,19 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
private readonly ConcurrentDictionary<string, SystemMessageHandler> _callbacks = new();
|
||||
private long _authErrorEventCount;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the system account used for advisory routing.
|
||||
/// </summary>
|
||||
public Account SystemAccount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal system client bound to system subscriptions.
|
||||
/// </summary>
|
||||
public InternalClient SystemClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hashed server identifier used in request/reply subjects.
|
||||
/// </summary>
|
||||
public string ServerHash { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -123,6 +192,13 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// </summary>
|
||||
public long AuthErrorEventCount => Interlocked.Read(ref _authErrorEventCount);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the internal event system and initializes send/receive channels.
|
||||
/// </summary>
|
||||
/// <param name="systemAccount">System account used for event publication and matching.</param>
|
||||
/// <param name="systemClient">Internal system client used for callback dispatch.</param>
|
||||
/// <param name="serverName">Server name input for deterministic server hash generation.</param>
|
||||
/// <param name="logger">Logger for send/receive loop diagnostics.</param>
|
||||
public InternalEventSystem(Account systemAccount, InternalClient systemClient, string serverName, ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -145,6 +221,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers.
|
||||
/// </summary>
|
||||
/// <param name="value">Input value to hash.</param>
|
||||
/// <param name="size">Number of hex characters to return.</param>
|
||||
public static string GetHash(string value, int size)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(size, 1);
|
||||
@@ -152,6 +230,10 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
return size >= full.Length ? full : full[..size];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts internal send/receive loops and periodic stats publishing.
|
||||
/// </summary>
|
||||
/// <param name="server">Owning server instance used for stat snapshots and event info.</param>
|
||||
public void Start(NatsServer server)
|
||||
{
|
||||
_server = server;
|
||||
@@ -177,6 +259,7 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Sets up handlers for $SYS.REQ.SERVER.{id}.VARZ, HEALTHZ, SUBSZ, STATSZ, IDZ
|
||||
/// and wildcard $SYS.REQ.SERVER.PING.* subjects.
|
||||
/// </summary>
|
||||
/// <param name="server">Owning server that handles system request subjects.</param>
|
||||
public void InitEventTracking(NatsServer server)
|
||||
{
|
||||
_server = server;
|
||||
@@ -258,6 +341,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Creates a system subscription in the system account's SubList.
|
||||
/// Maps to Go's sysSubscribe in events.go:2796.
|
||||
/// </summary>
|
||||
/// <param name="subject">System subject to subscribe to.</param>
|
||||
/// <param name="callback">Callback invoked for each matching internal message.</param>
|
||||
public Subscription SysSubscribe(string subject, SystemMessageHandler callback)
|
||||
{
|
||||
var sid = Interlocked.Increment(ref _subscriptionId).ToString();
|
||||
@@ -304,6 +389,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Increments <see cref="AuthErrorEventCount"/> each time it is called.
|
||||
/// Go reference: events.go:2631 sendAuthErrorEvent.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server identifier to embed in advisory metadata.</param>
|
||||
/// <param name="detail">Auth error event detail payload.</param>
|
||||
public void SendAuthErrorEvent(string serverId, AuthErrorDetail detail)
|
||||
{
|
||||
var subject = string.Format(EventSubjects.AuthError, serverId);
|
||||
@@ -330,6 +417,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Publishes a client connect advisory to $SYS.ACCOUNT.{account}.CONNECT.
|
||||
/// Go reference: events.go postConnectEvent / sendConnect.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server identifier to embed in advisory metadata.</param>
|
||||
/// <param name="detail">Connect advisory detail payload.</param>
|
||||
public void SendConnectEvent(string serverId, ConnectEventDetail detail)
|
||||
{
|
||||
var accountName = detail.AccountName ?? "$G";
|
||||
@@ -363,6 +452,8 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// Publishes a client disconnect advisory to $SYS.ACCOUNT.{account}.DISCONNECT.
|
||||
/// Go reference: events.go postDisconnectEvent / sendDisconnect.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Server identifier to embed in advisory metadata.</param>
|
||||
/// <param name="detail">Disconnect advisory detail payload.</param>
|
||||
public void SendDisconnectEvent(string serverId, DisconnectEventDetail detail)
|
||||
{
|
||||
var accountName = detail.AccountName ?? "$G";
|
||||
@@ -396,6 +487,7 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Enqueue an internal message for publishing through the send loop.
|
||||
/// </summary>
|
||||
/// <param name="message">Internal publish message to queue.</param>
|
||||
public void Enqueue(PublishMessage message)
|
||||
{
|
||||
_sendQueue.Writer.TryWrite(message);
|
||||
@@ -495,6 +587,9 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops event loops, completes channels, and disposes cancellation resources.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
@@ -45,6 +45,8 @@ public static class GatewayCommands
|
||||
/// Wire format: GS+ {account} {subject}\r\n
|
||||
/// Go reference: gateway.go — sendGatewaySubsToGateway, RS+ propagation.
|
||||
/// </summary>
|
||||
/// <param name="account">Origin account used for gateway interest tracking.</param>
|
||||
/// <param name="subject">Subject pattern being subscribed across clusters.</param>
|
||||
public static byte[] FormatSub(string account, string subject)
|
||||
=> Encoding.UTF8.GetBytes($"GS+ {account} {subject}\r\n");
|
||||
|
||||
@@ -53,6 +55,8 @@ public static class GatewayCommands
|
||||
/// Wire format: GS- {account} {subject}\r\n
|
||||
/// Go reference: gateway.go — sendGatewayUnsubToGateway, RS- propagation.
|
||||
/// </summary>
|
||||
/// <param name="account">Origin account used for gateway interest tracking.</param>
|
||||
/// <param name="subject">Subject pattern being removed from remote interest state.</param>
|
||||
public static byte[] FormatUnsub(string account, string subject)
|
||||
=> Encoding.UTF8.GetBytes($"GS- {account} {subject}\r\n");
|
||||
|
||||
@@ -62,6 +66,8 @@ public static class GatewayCommands
|
||||
/// Mode: "O" for Optimistic (send everything), "I" for Interest-only.
|
||||
/// Go reference: gateway.go — switchAccountToInterestMode, GMODE command.
|
||||
/// </summary>
|
||||
/// <param name="account">Account whose cross-cluster routing mode is being updated.</param>
|
||||
/// <param name="mode">Target gateway interest mode for that account.</param>
|
||||
public static byte[] FormatMode(string account, GatewayInterestMode mode)
|
||||
{
|
||||
var modeStr = mode == GatewayInterestMode.InterestOnly ? "I" : "O";
|
||||
@@ -73,6 +79,7 @@ public static class GatewayCommands
|
||||
/// Returns null if the command prefix is unrecognized.
|
||||
/// Go reference: gateway.go — processGatewayMsg command dispatch.
|
||||
/// </summary>
|
||||
/// <param name="line">Raw protocol line prefix read from a gateway connection.</param>
|
||||
public static GatewayCommandType? ParseCommandType(ReadOnlySpan<byte> line)
|
||||
{
|
||||
if (line.StartsWith(InfoPrefix)) return GatewayCommandType.Info;
|
||||
|
||||
@@ -15,10 +15,15 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _queueSubscriptions = new(StringComparer.Ordinal);
|
||||
private Task? _loopTask;
|
||||
|
||||
/// <summary>Remote gateway server id learned during handshake.</summary>
|
||||
public string? RemoteId { get; private set; }
|
||||
/// <summary>Indicates whether this is an outbound (solicited) gateway connection.</summary>
|
||||
public bool IsOutbound { get; internal set; }
|
||||
/// <summary>Remote endpoint string for diagnostics and monitoring.</summary>
|
||||
public string RemoteEndpoint => socket.RemoteEndPoint?.ToString() ?? Guid.NewGuid().ToString("N");
|
||||
/// <summary>Callback invoked when remote A+/A- interest updates are received.</summary>
|
||||
public Func<RemoteSubscription, Task>? RemoteSubscriptionReceived { get; set; }
|
||||
/// <summary>Callback invoked when remote GMSG payloads are received.</summary>
|
||||
public Func<GatewayMessage, Task>? MessageReceived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -31,6 +36,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// Adds a subject to the account-specific subscription set for this gateway connection.
|
||||
/// Go: gateway.go — per-account subscription routing state on outbound connections.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name for the subscription.</param>
|
||||
/// <param name="subject">Subject to track.</param>
|
||||
public void AddAccountSubscription(string account, string subject)
|
||||
{
|
||||
var subs = _accountSubscriptions.GetOrAdd(account, _ => new HashSet<string>(StringComparer.Ordinal));
|
||||
@@ -40,6 +47,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Removes a subject from the account-specific subscription set for this gateway connection.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name for the subscription.</param>
|
||||
/// <param name="subject">Subject to untrack.</param>
|
||||
public void RemoveAccountSubscription(string account, string subject)
|
||||
{
|
||||
if (_accountSubscriptions.TryGetValue(account, out var subs))
|
||||
@@ -49,6 +58,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all subjects tracked for the given account on this connection.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to query.</param>
|
||||
/// <returns>Snapshot of tracked subjects.</returns>
|
||||
public IReadOnlySet<string> GetAccountSubscriptions(string account)
|
||||
{
|
||||
if (_accountSubscriptions.TryGetValue(account, out var subs))
|
||||
@@ -59,6 +70,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Returns the number of subjects tracked for the given account. Returns 0 for unknown accounts.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name to query.</param>
|
||||
/// <returns>Number of tracked subjects for the account.</returns>
|
||||
public int AccountSubscriptionCount(string account)
|
||||
{
|
||||
if (_accountSubscriptions.TryGetValue(account, out var subs))
|
||||
@@ -70,6 +83,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// Registers a queue group subscription for propagation to this gateway.
|
||||
/// Go reference: gateway.go — sendQueueSubsToGateway.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject for the queue subscription.</param>
|
||||
/// <param name="queueGroup">Queue group name.</param>
|
||||
public void AddQueueSubscription(string subject, string queueGroup)
|
||||
{
|
||||
var groups = _queueSubscriptions.GetOrAdd(subject, _ => new HashSet<string>(StringComparer.Ordinal));
|
||||
@@ -80,6 +95,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// Removes a queue group subscription from this gateway connection's tracking state.
|
||||
/// Go reference: gateway.go — sendQueueSubsToGateway (removal path).
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject for the queue subscription.</param>
|
||||
/// <param name="queueGroup">Queue group name.</param>
|
||||
public void RemoveQueueSubscription(string subject, string queueGroup)
|
||||
{
|
||||
if (_queueSubscriptions.TryGetValue(subject, out var groups))
|
||||
@@ -89,6 +106,8 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all queue group names registered for the given subject.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to query.</param>
|
||||
/// <returns>Snapshot of queue group names.</returns>
|
||||
public IReadOnlySet<string> GetQueueGroups(string subject)
|
||||
{
|
||||
if (_queueSubscriptions.TryGetValue(subject, out var groups))
|
||||
@@ -104,6 +123,9 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Returns true if the given subject/queueGroup pair is currently registered on this gateway connection.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to query.</param>
|
||||
/// <param name="queueGroup">Queue group name to query.</param>
|
||||
/// <returns><see langword="true"/> when the pair is registered.</returns>
|
||||
public bool HasQueueSubscription(string subject, string queueGroup)
|
||||
{
|
||||
if (!_queueSubscriptions.TryGetValue(subject, out var groups))
|
||||
@@ -111,6 +133,11 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
lock (groups) return groups.Contains(queueGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs outbound gateway handshake by sending local id and reading remote id.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Local server id.</param>
|
||||
/// <param name="ct">Cancellation token for I/O operations.</param>
|
||||
public async Task PerformOutboundHandshakeAsync(string serverId, CancellationToken ct)
|
||||
{
|
||||
await WriteLineAsync($"GATEWAY {serverId}", ct);
|
||||
@@ -118,6 +145,11 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
RemoteId = ParseHandshake(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs inbound gateway handshake by reading remote id and sending local id.
|
||||
/// </summary>
|
||||
/// <param name="serverId">Local server id.</param>
|
||||
/// <param name="ct">Cancellation token for I/O operations.</param>
|
||||
public async Task PerformInboundHandshakeAsync(string serverId, CancellationToken ct)
|
||||
{
|
||||
var line = await ReadLineAsync(ct);
|
||||
@@ -125,6 +157,10 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
await WriteLineAsync($"GATEWAY {serverId}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background frame read loop for this connection.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token controlling loop lifetime.</param>
|
||||
public void StartLoop(CancellationToken ct)
|
||||
{
|
||||
if (_loopTask != null)
|
||||
@@ -134,15 +170,42 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
_loopTask = Task.Run(() => ReadLoopAsync(linked.Token), linked.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the gateway read loop to exit.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token for wait operation.</param>
|
||||
/// <returns>A task that completes when loop exits.</returns>
|
||||
public Task WaitUntilClosedAsync(CancellationToken ct)
|
||||
=> _loopTask?.WaitAsync(ct) ?? Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Sends an A+ protocol line to advertise interest.
|
||||
/// </summary>
|
||||
/// <param name="account">Account for the interest update.</param>
|
||||
/// <param name="subject">Subject being added.</param>
|
||||
/// <param name="queue">Optional queue group.</param>
|
||||
/// <param name="ct">Cancellation token for I/O operations.</param>
|
||||
public Task SendAPlusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
=> WriteLineAsync(queue is { Length: > 0 } ? $"A+ {account} {subject} {queue}" : $"A+ {account} {subject}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an A- protocol line to remove advertised interest.
|
||||
/// </summary>
|
||||
/// <param name="account">Account for the interest update.</param>
|
||||
/// <param name="subject">Subject being removed.</param>
|
||||
/// <param name="queue">Optional queue group.</param>
|
||||
/// <param name="ct">Cancellation token for I/O operations.</param>
|
||||
public Task SendAMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
=> WriteLineAsync(queue is { Length: > 0 } ? $"A- {account} {subject} {queue}" : $"A- {account} {subject}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GMSG payload to the remote gateway when interest permits forwarding.
|
||||
/// </summary>
|
||||
/// <param name="account">Account associated with the message.</param>
|
||||
/// <param name="subject">Subject being forwarded.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="payload">Payload bytes.</param>
|
||||
/// <param name="ct">Cancellation token for I/O operations.</param>
|
||||
public async Task SendMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
// Go: gateway.go:2900 (shouldForwardMsg) — check interest tracker before sending
|
||||
@@ -166,6 +229,9 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes this gateway connection and stops background processing.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _closedCts.CancelAsync();
|
||||
|
||||
@@ -42,6 +42,10 @@ public sealed class GatewayInterestTracker
|
||||
// Per-account state: mode + no-interest set (Optimistic) or positive interest set (InterestOnly)
|
||||
private readonly ConcurrentDictionary<string, AccountState> _accounts = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a gateway interest tracker with a configurable mode-switch threshold.
|
||||
/// </summary>
|
||||
/// <param name="noInterestThreshold">No-interest entry count that triggers InterestOnly mode.</param>
|
||||
public GatewayInterestTracker(int noInterestThreshold = DefaultNoInterestThreshold)
|
||||
{
|
||||
_noInterestThreshold = noInterestThreshold;
|
||||
@@ -51,6 +55,7 @@ public sealed class GatewayInterestTracker
|
||||
/// Returns the current interest mode for the given account.
|
||||
/// Accounts default to Optimistic until the no-interest threshold is exceeded.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name/identifier.</param>
|
||||
public GatewayInterestMode GetMode(string account)
|
||||
=> _accounts.TryGetValue(account, out var state) ? state.Mode : GatewayInterestMode.Optimistic;
|
||||
|
||||
@@ -58,6 +63,8 @@ public sealed class GatewayInterestTracker
|
||||
/// Track a positive interest (RS+ received from remote) for an account/subject.
|
||||
/// Go: gateway.go:1540 (processGatewayAccountSub — adds to interest set)
|
||||
/// </summary>
|
||||
/// <param name="account">Account name/identifier.</param>
|
||||
/// <param name="subject">Subject or pattern with positive remote interest.</param>
|
||||
public void TrackInterest(string account, string subject)
|
||||
{
|
||||
var state = GetOrCreateState(account);
|
||||
@@ -83,6 +90,8 @@ public sealed class GatewayInterestTracker
|
||||
/// When the no-interest set crosses the threshold, switches to InterestOnly mode.
|
||||
/// Go: gateway.go:1560 (processGatewayAccountUnsub — tracks no-interest, triggers switch)
|
||||
/// </summary>
|
||||
/// <param name="account">Account name/identifier.</param>
|
||||
/// <param name="subject">Subject or pattern that should be treated as no-interest.</param>
|
||||
public void TrackNoInterest(string account, string subject)
|
||||
{
|
||||
var state = GetOrCreateState(account);
|
||||
@@ -110,6 +119,8 @@ public sealed class GatewayInterestTracker
|
||||
/// for the given account and subject.
|
||||
/// Go: gateway.go:2900 (shouldForwardMsg — checks mode and interest)
|
||||
/// </summary>
|
||||
/// <param name="account">Account name/identifier.</param>
|
||||
/// <param name="subject">Subject being considered for forwarding.</param>
|
||||
public bool ShouldForward(string account, string subject)
|
||||
{
|
||||
if (!_accounts.TryGetValue(account, out var state))
|
||||
@@ -141,6 +152,7 @@ public sealed class GatewayInterestTracker
|
||||
/// Called when the remote signals it is in interest-only mode.
|
||||
/// Go: gateway.go:1500 (switchToInterestOnlyMode)
|
||||
/// </summary>
|
||||
/// <param name="account">Account name/identifier.</param>
|
||||
public void SwitchToInterestOnly(string account)
|
||||
{
|
||||
var state = GetOrCreateState(account);
|
||||
@@ -179,6 +191,7 @@ public sealed class GatewayInterestTracker
|
||||
/// <summary>Per-account mutable state. All access must be under the instance lock.</summary>
|
||||
private sealed class AccountState
|
||||
{
|
||||
/// <summary>Current forwarding mode for this account.</summary>
|
||||
public GatewayInterestMode Mode { get; set; } = GatewayInterestMode.Optimistic;
|
||||
|
||||
/// <summary>Subjects with no remote interest (used in Optimistic mode).</summary>
|
||||
|
||||
@@ -28,12 +28,33 @@ public sealed class GatewayRegistration
|
||||
internal long _messagesSent;
|
||||
internal long _messagesReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets gateway name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
/// <summary>
|
||||
/// Gets or sets current connection state.
|
||||
/// </summary>
|
||||
public GatewayConnectionState State { get; set; } = GatewayConnectionState.Connecting;
|
||||
/// <summary>
|
||||
/// Gets or sets UTC timestamp when gateway reached connected state.
|
||||
/// </summary>
|
||||
public DateTime ConnectedAtUtc { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets UTC timestamp when gateway reached disconnected state.
|
||||
/// </summary>
|
||||
public DateTime? DisconnectedAtUtc { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets remote endpoint address string.
|
||||
/// </summary>
|
||||
public string? RemoteAddress { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets cumulative messages sent counter.
|
||||
/// </summary>
|
||||
public long MessagesSent { get => Interlocked.Read(ref _messagesSent); set => Interlocked.Exchange(ref _messagesSent, value); }
|
||||
/// <summary>
|
||||
/// Gets or sets cumulative messages received counter.
|
||||
/// </summary>
|
||||
public long MessagesReceived { get => Interlocked.Read(ref _messagesReceived); set => Interlocked.Exchange(ref _messagesReceived, value); }
|
||||
}
|
||||
|
||||
@@ -43,11 +64,28 @@ public sealed class GatewayRegistration
|
||||
/// </summary>
|
||||
public sealed class GatewayReconnectPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets initial reconnect delay.
|
||||
/// </summary>
|
||||
public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1);
|
||||
/// <summary>
|
||||
/// Gets maximum reconnect delay.
|
||||
/// </summary>
|
||||
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(30);
|
||||
/// <summary>
|
||||
/// Gets random jitter factor applied to reconnect delays.
|
||||
/// </summary>
|
||||
public double JitterFactor { get; init; } = 0.2;
|
||||
/// <summary>
|
||||
/// Gets maximum reconnect attempts.
|
||||
/// </summary>
|
||||
public int MaxAttempts { get; init; } = int.MaxValue; // 0 = unlimited
|
||||
|
||||
/// <summary>
|
||||
/// Calculates exponential reconnect delay without jitter.
|
||||
/// </summary>
|
||||
/// <param name="attempt">Reconnect attempt index.</param>
|
||||
/// <returns>Calculated delay.</returns>
|
||||
public TimeSpan CalculateDelay(int attempt)
|
||||
{
|
||||
var baseDelay = InitialDelay.TotalMilliseconds * Math.Pow(2, Math.Min(attempt, 10));
|
||||
@@ -55,6 +93,11 @@ public sealed class GatewayReconnectPolicy
|
||||
return TimeSpan.FromMilliseconds(capped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates exponential reconnect delay with jitter.
|
||||
/// </summary>
|
||||
/// <param name="attempt">Reconnect attempt index.</param>
|
||||
/// <returns>Calculated jittered delay.</returns>
|
||||
public TimeSpan CalculateDelayWithJitter(int attempt)
|
||||
{
|
||||
var delay = CalculateDelay(attempt);
|
||||
@@ -84,12 +127,34 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
private Socket? _listener;
|
||||
private Task? _acceptLoopTask;
|
||||
|
||||
/// <summary>
|
||||
/// Gets local gateway listener endpoint in host:port form.
|
||||
/// </summary>
|
||||
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
|
||||
/// <summary>
|
||||
/// Gets number of forwarded JetStream cluster messages.
|
||||
/// </summary>
|
||||
public long ForwardedJetStreamClusterMessages => Interlocked.Read(ref _forwardedJetStreamClusterMessages);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether interest-only forwarding should occur for a publish.
|
||||
/// </summary>
|
||||
/// <param name="subList">Subscription list to evaluate.</param>
|
||||
/// <param name="account">Account name.</param>
|
||||
/// <param name="subject">Published subject.</param>
|
||||
/// <returns>True when remote interest exists.</returns>
|
||||
internal static bool ShouldForwardInterestOnly(SubList subList, string account, string subject)
|
||||
=> subList.HasRemoteInterest(account, subject);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a gateway manager.
|
||||
/// </summary>
|
||||
/// <param name="options">Gateway options.</param>
|
||||
/// <param name="stats">Server stats sink.</param>
|
||||
/// <param name="serverId">Local server ID.</param>
|
||||
/// <param name="remoteSubSink">Remote subscription callback.</param>
|
||||
/// <param name="messageSink">Inbound gateway message callback.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public GatewayManager(
|
||||
GatewayOptions options,
|
||||
ServerStats stats,
|
||||
@@ -110,6 +175,9 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Validates gateway options for required fields and basic endpoint correctness.
|
||||
/// Go reference: validateGatewayOptions.
|
||||
/// </summary>
|
||||
/// <param name="options">Gateway options to validate.</param>
|
||||
/// <param name="error">Validation error message when invalid.</param>
|
||||
/// <returns>True when options are valid.</returns>
|
||||
public static bool ValidateGatewayOptions(GatewayOptions? options, out string? error)
|
||||
{
|
||||
if (options is null)
|
||||
@@ -156,6 +224,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Processes a gateway info message from a peer, discovering new gateway clusters.
|
||||
/// Go reference: server/gateway.go:800-850 (processImplicitGateway).
|
||||
/// </summary>
|
||||
/// <param name="gwInfo">Discovered gateway info.</param>
|
||||
public void ProcessImplicitGateway(GatewayInfo gwInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gwInfo);
|
||||
@@ -171,6 +240,8 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Returns 0 if no reconnect attempt has been recorded yet.
|
||||
/// Go reference: server/gateway.go reconnectGateway attempt tracking.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name.</param>
|
||||
/// <returns>Reconnect attempt count.</returns>
|
||||
public int GetReconnectAttempts(string gatewayName)
|
||||
=> _reconnectAttempts.TryGetValue(gatewayName, out var n) ? n : 0;
|
||||
|
||||
@@ -178,6 +249,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Resets the reconnect attempt counter for a named gateway (called on successful connection).
|
||||
/// Go reference: server/gateway.go solicitGateway successful connect path.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name.</param>
|
||||
public void ResetReconnectAttempts(string gatewayName)
|
||||
=> _reconnectAttempts.TryRemove(gatewayName, out _);
|
||||
|
||||
@@ -186,6 +258,9 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Increments the attempt counter, waits the backoff delay, then attempts to connect.
|
||||
/// Go reference: server/gateway.go reconnectGateway / solicitGateway.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that completes after backoff delay.</returns>
|
||||
public async Task ReconnectGatewayAsync(string gatewayName, CancellationToken ct)
|
||||
{
|
||||
var attempt = _reconnectAttempts.AddOrUpdate(gatewayName, 1, (_, n) => n + 1);
|
||||
@@ -198,6 +273,11 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts gateway listener and outbound connector loops.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A completed task when startup work is scheduled.</returns>
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -228,24 +308,51 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a message to all connected gateways.
|
||||
/// </summary>
|
||||
/// <param name="account">Publishing account.</param>
|
||||
/// <param name="subject">Message subject.</param>
|
||||
/// <param name="replyTo">Optional reply subject.</param>
|
||||
/// <param name="payload">Message payload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that completes when forwarding finishes.</returns>
|
||||
public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
foreach (var connection in _connections.Values)
|
||||
await connection.SendMessageAsync(account, subject, replyTo, payload, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a JetStream cluster message to gateways.
|
||||
/// </summary>
|
||||
/// <param name="message">Gateway message envelope.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that completes when forwarding finishes.</returns>
|
||||
public async Task ForwardJetStreamClusterMessageAsync(GatewayMessage message, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _forwardedJetStreamClusterMessages);
|
||||
await ForwardMessageAsync(message.Account, message.Subject, message.ReplyTo, message.Payload, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local subscription to gateway peers.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name.</param>
|
||||
/// <param name="subject">Subscribed subject.</param>
|
||||
/// <param name="queue">Optional queue group.</param>
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||
{
|
||||
foreach (var connection in _connections.Values)
|
||||
_ = connection.SendAPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates a local unsubscription to gateway peers.
|
||||
/// </summary>
|
||||
/// <param name="account">Account name.</param>
|
||||
/// <param name="subject">Unsubscribed subject.</param>
|
||||
/// <param name="queue">Optional queue group.</param>
|
||||
public void PropagateLocalUnsubscription(string account, string subject, string? queue)
|
||||
{
|
||||
foreach (var connection in _connections.Values)
|
||||
@@ -257,6 +364,9 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// them in that connection's per-account subscription set.
|
||||
/// Go: gateway.go — account-specific subscription propagation on outbound routes.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name.</param>
|
||||
/// <param name="account">Account name.</param>
|
||||
/// <param name="subjects">Subjects to propagate.</param>
|
||||
public void SendAccountSubscriptions(string gatewayName, string account, IEnumerable<string> subjects)
|
||||
{
|
||||
if (!_connections.TryGetValue(gatewayName, out var conn)) return;
|
||||
@@ -268,6 +378,9 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Returns a snapshot of all subjects tracked for the given account on the named gateway connection.
|
||||
/// Returns an empty set when the connection is not found.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name.</param>
|
||||
/// <param name="account">Account name.</param>
|
||||
/// <returns>Tracked subject set.</returns>
|
||||
public IReadOnlySet<string> GetAccountSubscriptions(string gatewayName, string account)
|
||||
{
|
||||
if (!_connections.TryGetValue(gatewayName, out var conn))
|
||||
@@ -281,6 +394,8 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Registers a new gateway by name, starting in the Connecting state.
|
||||
/// Go reference: server/gateway.go solicitGateway creates outbound entry before dialling.
|
||||
/// </summary>
|
||||
/// <param name="name">Gateway name.</param>
|
||||
/// <param name="remoteAddress">Optional remote endpoint address.</param>
|
||||
public void RegisterGateway(string name, string? remoteAddress = null)
|
||||
{
|
||||
var reg = new GatewayRegistration
|
||||
@@ -297,6 +412,8 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Setting Connected stamps ConnectedAtUtc; setting Disconnected stamps DisconnectedAtUtc.
|
||||
/// Go reference: server/gateway.go gwConnState transitions.
|
||||
/// </summary>
|
||||
/// <param name="name">Gateway name.</param>
|
||||
/// <param name="state">New connection state.</param>
|
||||
public void UpdateState(string name, GatewayConnectionState state)
|
||||
{
|
||||
if (!_registrations.TryGetValue(name, out var reg)) return;
|
||||
@@ -311,6 +428,8 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Returns the registration for the named gateway, or null if not registered.
|
||||
/// Go reference: server/gateway.go server.getOutboundGatewayConnection.
|
||||
/// </summary>
|
||||
/// <param name="name">Gateway name.</param>
|
||||
/// <returns>Registration snapshot, or null.</returns>
|
||||
public GatewayRegistration? GetRegistration(string name)
|
||||
=> _registrations.TryGetValue(name, out var reg) ? reg : null;
|
||||
|
||||
@@ -324,6 +443,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Removes the named gateway registration.
|
||||
/// Go reference: server/gateway.go outboundGateway teardown.
|
||||
/// </summary>
|
||||
/// <param name="name">Gateway name.</param>
|
||||
public void UnregisterGateway(string name)
|
||||
=> _registrations.TryRemove(name, out _);
|
||||
|
||||
@@ -352,6 +472,8 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Returns true if an inbound gateway connection exists for the given remote server id.
|
||||
/// Go reference: server/gateway.go srvGateway.hasInbound.
|
||||
/// </summary>
|
||||
/// <param name="remoteServerId">Remote server ID.</param>
|
||||
/// <returns>True when inbound exists.</returns>
|
||||
public bool HasInbound(string remoteServerId)
|
||||
=> _connections.Values.Any(c => !c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
|
||||
|
||||
@@ -359,6 +481,8 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Returns the first outbound gateway connection for the given remote server id, or null.
|
||||
/// Go reference: server/gateway.go getOutboundGatewayConnection.
|
||||
/// </summary>
|
||||
/// <param name="remoteServerId">Remote server ID.</param>
|
||||
/// <returns>Outbound connection or null.</returns>
|
||||
public GatewayConnection? GetOutboundGatewayConnection(string remoteServerId)
|
||||
=> _connections.Values.FirstOrDefault(c => c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
|
||||
|
||||
@@ -380,6 +504,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Atomically increments the messages-sent counter for the named gateway.
|
||||
/// Go reference: server/gateway.go outboundGateway.msgs.
|
||||
/// </summary>
|
||||
/// <param name="name">Gateway name.</param>
|
||||
public void IncrementMessagesSent(string name)
|
||||
{
|
||||
if (_registrations.TryGetValue(name, out var reg))
|
||||
@@ -390,12 +515,17 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
/// Atomically increments the messages-received counter for the named gateway.
|
||||
/// Go reference: server/gateway.go inboundGateway.msgs.
|
||||
/// </summary>
|
||||
/// <param name="name">Gateway name.</param>
|
||||
public void IncrementMessagesReceived(string name)
|
||||
{
|
||||
if (_registrations.TryGetValue(name, out var reg))
|
||||
Interlocked.Increment(ref reg._messagesReceived);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops listener, tears down gateway connections, and releases resources.
|
||||
/// </summary>
|
||||
/// <returns>A task that completes when disposal finishes.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts == null)
|
||||
|
||||
@@ -20,6 +20,8 @@ public static class ReplyMapper
|
||||
/// Checks whether the subject starts with either gateway reply prefix:
|
||||
/// <c>_GR_.</c> (current) or <c>$GR.</c> (legacy).
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to inspect.</param>
|
||||
/// <returns><see langword="true"/> when the subject is gateway-routed.</returns>
|
||||
public static bool HasGatewayReplyPrefix(string? subject)
|
||||
=> IsGatewayRoutedSubject(subject, out _);
|
||||
|
||||
@@ -28,6 +30,9 @@ public static class ReplyMapper
|
||||
/// old prefix (<c>$GR.</c>) was used.
|
||||
/// Go reference: isGWRoutedSubjectAndIsOldPrefix.
|
||||
/// </summary>
|
||||
/// <param name="subject">Subject to inspect.</param>
|
||||
/// <param name="isOldPrefix">Set to <see langword="true"/> when the legacy prefix is used.</param>
|
||||
/// <returns><see langword="true"/> when the subject is gateway-routed.</returns>
|
||||
public static bool IsGatewayRoutedSubject(string? subject, out bool isOldPrefix)
|
||||
{
|
||||
isOldPrefix = false;
|
||||
@@ -51,6 +56,8 @@ public static class ReplyMapper
|
||||
/// Go reference: gateway.go uses SHA-256 truncated to base-62; we use FNV-1a for speed
|
||||
/// while maintaining determinism and good distribution.
|
||||
/// </summary>
|
||||
/// <param name="replyTo">Reply subject to hash.</param>
|
||||
/// <returns>Non-negative deterministic hash value.</returns>
|
||||
public static long ComputeReplyHash(string replyTo)
|
||||
{
|
||||
// FNV-1a 64-bit
|
||||
@@ -72,6 +79,8 @@ public static class ReplyMapper
|
||||
/// Computes the short (6-char) gateway hash used in modern gateway reply routing.
|
||||
/// Go reference: getGWHash.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name to hash.</param>
|
||||
/// <returns>Lowercase 6-character hash token.</returns>
|
||||
public static string ComputeGatewayHash(string gatewayName)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
|
||||
@@ -82,6 +91,8 @@ public static class ReplyMapper
|
||||
/// Computes the short (4-char) legacy gateway hash used with old prefixes.
|
||||
/// Go reference: getOldHash.
|
||||
/// </summary>
|
||||
/// <param name="gatewayName">Gateway name to hash.</param>
|
||||
/// <returns>Lowercase 4-character hash token.</returns>
|
||||
public static string ComputeOldGatewayHash(string gatewayName)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
|
||||
@@ -92,6 +103,10 @@ public static class ReplyMapper
|
||||
/// Converts a reply subject to gateway form with an explicit hash segment.
|
||||
/// Format: <c>_GR_.{clusterId}.{hash}.{originalReply}</c>.
|
||||
/// </summary>
|
||||
/// <param name="replyTo">Original reply subject.</param>
|
||||
/// <param name="localClusterId">Local cluster identifier to embed.</param>
|
||||
/// <param name="hash">Precomputed reply hash.</param>
|
||||
/// <returns>Gateway-form reply subject, or original when null/empty.</returns>
|
||||
public static string? ToGatewayReply(string? replyTo, string localClusterId, long hash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(replyTo))
|
||||
@@ -104,6 +119,9 @@ public static class ReplyMapper
|
||||
/// Converts a reply subject to gateway form, automatically computing the hash.
|
||||
/// Format: <c>_GR_.{clusterId}.{hash}.{originalReply}</c>.
|
||||
/// </summary>
|
||||
/// <param name="replyTo">Original reply subject.</param>
|
||||
/// <param name="localClusterId">Local cluster identifier to embed.</param>
|
||||
/// <returns>Gateway-form reply subject, or original when null/empty.</returns>
|
||||
public static string? ToGatewayReply(string? replyTo, string localClusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(replyTo))
|
||||
@@ -119,6 +137,9 @@ public static class ReplyMapper
|
||||
/// legacy format (<c>_GR_.{clusterId}.{originalReply}</c>).
|
||||
/// Nested prefixes are unwrapped iteratively.
|
||||
/// </summary>
|
||||
/// <param name="gatewayReply">Gateway-form reply subject.</param>
|
||||
/// <param name="restoredReply">Receives restored original reply subject on success.</param>
|
||||
/// <returns><see langword="true"/> when restoration succeeds.</returns>
|
||||
public static bool TryRestoreGatewayReply(string? gatewayReply, out string restoredReply)
|
||||
{
|
||||
restoredReply = string.Empty;
|
||||
@@ -161,6 +182,9 @@ public static class ReplyMapper
|
||||
/// Extracts the cluster ID from a gateway reply subject.
|
||||
/// The cluster ID is the first segment after the <c>_GR_.</c> prefix.
|
||||
/// </summary>
|
||||
/// <param name="gatewayReply">Gateway-form reply subject.</param>
|
||||
/// <param name="clusterId">Receives extracted cluster identifier on success.</param>
|
||||
/// <returns><see langword="true"/> when extraction succeeds.</returns>
|
||||
public static bool TryExtractClusterId(string? gatewayReply, out string clusterId)
|
||||
{
|
||||
clusterId = string.Empty;
|
||||
@@ -181,6 +205,9 @@ public static class ReplyMapper
|
||||
/// Extracts the hash from a gateway reply subject (new format only).
|
||||
/// Returns false if the reply uses the legacy format without a hash.
|
||||
/// </summary>
|
||||
/// <param name="gatewayReply">Gateway-form reply subject.</param>
|
||||
/// <param name="hash">Receives extracted hash on success.</param>
|
||||
/// <returns><see langword="true"/> when extraction succeeds.</returns>
|
||||
public static bool TryExtractHash(string? gatewayReply, out long hash)
|
||||
{
|
||||
hash = 0;
|
||||
@@ -236,6 +263,11 @@ public sealed class ReplyMapCache
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an LRU reply mapping cache with TTL expiration.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Maximum number of entries to retain.</param>
|
||||
/// <param name="ttlMs">Time-to-live for entries in milliseconds.</param>
|
||||
public ReplyMapCache(int capacity = 4096, int ttlMs = 60_000)
|
||||
{
|
||||
_capacity = capacity;
|
||||
@@ -243,10 +275,19 @@ public sealed class ReplyMapCache
|
||||
_map = new Dictionary<string, LinkedListNode<CacheEntry>>(capacity, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Total cache hits since creation.</summary>
|
||||
public long Hits => Interlocked.Read(ref _hits);
|
||||
/// <summary>Total cache misses since creation.</summary>
|
||||
public long Misses => Interlocked.Read(ref _misses);
|
||||
/// <summary>Current number of entries in the cache.</summary>
|
||||
public int Count { get { lock (_lock) return _map.Count; } }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get a cached mapping value.
|
||||
/// </summary>
|
||||
/// <param name="key">Cache lookup key.</param>
|
||||
/// <param name="value">Resolved cached value when found and not expired.</param>
|
||||
/// <returns><see langword="true"/> when an unexpired value exists.</returns>
|
||||
public bool TryGet(string key, out string? value)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -276,6 +317,11 @@ public sealed class ReplyMapCache
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts or updates a cached mapping value.
|
||||
/// </summary>
|
||||
/// <param name="key">Cache key.</param>
|
||||
/// <param name="value">Cache value.</param>
|
||||
public void Set(string key, string value)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -302,6 +348,9 @@ public sealed class ReplyMapCache
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached mappings.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -5,18 +5,45 @@ namespace NATS.Server;
|
||||
|
||||
public interface INatsClient
|
||||
{
|
||||
/// <summary>Unique server-assigned client identifier.</summary>
|
||||
ulong Id { get; }
|
||||
/// <summary>Client kind (client, route, gateway, leaf, system, etc.).</summary>
|
||||
ClientKind Kind { get; }
|
||||
/// <summary>Whether this client is server-internal and not socket-backed.</summary>
|
||||
bool IsInternal => Kind.IsInternal();
|
||||
/// <summary>Account context associated with this client.</summary>
|
||||
Account? Account { get; }
|
||||
/// <summary>Parsed CONNECT options for this client when available.</summary>
|
||||
ClientOptions? ClientOpts { get; }
|
||||
/// <summary>Resolved publish/subscribe permissions for this client.</summary>
|
||||
ClientPermissions? Permissions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a protocol message to a subscription with immediate flush semantics.
|
||||
/// </summary>
|
||||
/// <param name="subject">Delivery subject sent to the client.</param>
|
||||
/// <param name="sid">Subscription identifier receiving the message.</param>
|
||||
/// <param name="replyTo">Optional reply subject for request-reply flows.</param>
|
||||
/// <param name="headers">Serialized NATS headers payload.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
void SendMessage(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
|
||||
/// <summary>
|
||||
/// Sends a protocol message without forcing an immediate flush.
|
||||
/// </summary>
|
||||
/// <param name="subject">Delivery subject sent to the client.</param>
|
||||
/// <param name="sid">Subscription identifier receiving the message.</param>
|
||||
/// <param name="replyTo">Optional reply subject for request-reply flows.</param>
|
||||
/// <param name="headers">Serialized NATS headers payload.</param>
|
||||
/// <param name="payload">Message payload bytes.</param>
|
||||
void SendMessageNoFlush(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
|
||||
/// <summary>Signals that queued outbound bytes should be flushed.</summary>
|
||||
void SignalFlush();
|
||||
/// <summary>Queues outbound protocol bytes for asynchronous write-loop transmission.</summary>
|
||||
/// <param name="data">Serialized protocol bytes to queue.</param>
|
||||
bool QueueOutbound(ReadOnlyMemory<byte> data);
|
||||
/// <summary>Removes a subscription by subscription identifier.</summary>
|
||||
/// <param name="sid">Subscription identifier to remove.</param>
|
||||
void RemoveSubscription(string sid);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,19 @@ public sealed class AdaptiveReadBuffer
|
||||
private int _target = 4096;
|
||||
private int _consecutiveShortReads;
|
||||
|
||||
/// <summary>
|
||||
/// Current target buffer size used for the next socket read operation.
|
||||
/// </summary>
|
||||
public int CurrentSize => Math.Clamp(_target, 512, 64 * 1024);
|
||||
|
||||
/// <summary>Number of consecutive short reads since last full read or grow.</summary>
|
||||
public int ConsecutiveShortReads => _consecutiveShortReads;
|
||||
|
||||
/// <summary>
|
||||
/// Updates adaptive sizing state using the number of bytes returned by the latest read.
|
||||
/// Full reads bias toward growth for throughput, repeated short reads bias toward shrink to save memory.
|
||||
/// </summary>
|
||||
/// <param name="bytesRead">Byte count returned by the transport read.</param>
|
||||
public void RecordRead(int bytesRead)
|
||||
{
|
||||
if (bytesRead <= 0)
|
||||
|
||||
@@ -23,8 +23,11 @@ public sealed class OutboundBufferPool
|
||||
private long _returnCount;
|
||||
private long _broadcastCount;
|
||||
|
||||
/// <summary>Total buffer rent operations served by the pool.</summary>
|
||||
public long RentCount => Interlocked.Read(ref _rentCount);
|
||||
/// <summary>Total buffer return operations accepted by the pool.</summary>
|
||||
public long ReturnCount => Interlocked.Read(ref _returnCount);
|
||||
/// <summary>Total broadcast-drain operations performed.</summary>
|
||||
public long BroadcastCount => Interlocked.Read(ref _broadcastCount);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -36,6 +39,7 @@ public sealed class OutboundBufferPool
|
||||
/// <paramref name="size"/> bytes. Tries the internal pool first; falls back to
|
||||
/// <see cref="MemoryPool{T}.Shared"/>.
|
||||
/// </summary>
|
||||
/// <param name="size">Minimum required buffer size.</param>
|
||||
public IMemoryOwner<byte> Rent(int size)
|
||||
{
|
||||
Interlocked.Increment(ref _rentCount);
|
||||
@@ -70,6 +74,7 @@ public sealed class OutboundBufferPool
|
||||
/// <paramref name="size"/> bytes. The caller is responsible for calling
|
||||
/// <see cref="ReturnBuffer"/> when finished.
|
||||
/// </summary>
|
||||
/// <param name="size">Minimum required buffer size.</param>
|
||||
public byte[] RentBuffer(int size)
|
||||
{
|
||||
Interlocked.Increment(ref _rentCount);
|
||||
@@ -94,6 +99,7 @@ public sealed class OutboundBufferPool
|
||||
/// Returns <paramref name="buffer"/> to the appropriate tier so it can be
|
||||
/// reused by a subsequent <see cref="RentBuffer"/> call.
|
||||
/// </summary>
|
||||
/// <param name="buffer">Buffer previously rented from this pool.</param>
|
||||
public void ReturnBuffer(byte[] buffer)
|
||||
{
|
||||
Interlocked.Increment(ref _returnCount);
|
||||
@@ -128,6 +134,8 @@ public sealed class OutboundBufferPool
|
||||
///
|
||||
/// Go reference: client.go — broadcast flush coalescing for fan-out.
|
||||
/// </summary>
|
||||
/// <param name="pendingWrites">Pending write segments to coalesce.</param>
|
||||
/// <param name="destination">Destination buffer receiving the concatenated payloads.</param>
|
||||
public int BroadcastDrain(IReadOnlyList<ReadOnlyMemory<byte>> pendingWrites, byte[] destination)
|
||||
{
|
||||
var offset = 0;
|
||||
@@ -144,6 +152,7 @@ public sealed class OutboundBufferPool
|
||||
/// Returns the total number of bytes needed to coalesce all
|
||||
/// <paramref name="pendingWrites"/> into a single buffer.
|
||||
/// </summary>
|
||||
/// <param name="pendingWrites">Pending write segments to size.</param>
|
||||
public static int CalculateBroadcastSize(IReadOnlyList<ReadOnlyMemory<byte>> pendingWrites)
|
||||
{
|
||||
var total = 0;
|
||||
@@ -164,15 +173,22 @@ public sealed class OutboundBufferPool
|
||||
private readonly ConcurrentBag<byte[]> _pool;
|
||||
private byte[]? _buffer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pooled memory owner backed by a reusable byte array.
|
||||
/// </summary>
|
||||
/// <param name="buffer">Rented backing buffer.</param>
|
||||
/// <param name="pool">Pool to return the buffer to on disposal.</param>
|
||||
public PooledMemoryOwner(byte[] buffer, ConcurrentBag<byte[]> pool)
|
||||
{
|
||||
_buffer = buffer;
|
||||
_pool = pool;
|
||||
}
|
||||
|
||||
/// <summary>Memory view over the currently owned buffer.</summary>
|
||||
public Memory<byte> Memory =>
|
||||
_buffer is { } b ? b.AsMemory() : Memory<byte>.Empty;
|
||||
|
||||
/// <summary>Returns the owned buffer to the originating pool.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _buffer, null) is { } b)
|
||||
|
||||
@@ -4,11 +4,30 @@ namespace NATS.Server.Imports;
|
||||
|
||||
public sealed class ExportAuth
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether importers must present a token for access.
|
||||
/// </summary>
|
||||
public bool TokenRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the account-token subject position used for legacy tokenized export patterns.
|
||||
/// </summary>
|
||||
public uint AccountPosition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets explicit account names permitted to import this export.
|
||||
/// </summary>
|
||||
public HashSet<string>? ApprovedAccounts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets accounts revoked from import access, mapped to revocation timestamps.
|
||||
/// </summary>
|
||||
public Dictionary<string, long>? RevokedAccounts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified account is currently authorized for this export.
|
||||
/// </summary>
|
||||
/// <param name="account">Importing account requesting access.</param>
|
||||
public bool IsAuthorized(Account account)
|
||||
{
|
||||
if (RevokedAccounts != null && RevokedAccounts.ContainsKey(account.Name))
|
||||
|
||||
@@ -2,7 +2,18 @@ namespace NATS.Server.Imports;
|
||||
|
||||
public sealed class ExportMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets stream exports keyed by exported subject.
|
||||
/// </summary>
|
||||
public Dictionary<string, StreamExport> Streams { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets service exports keyed by exported subject.
|
||||
/// </summary>
|
||||
public Dictionary<string, ServiceExport> Services { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets temporary response imports keyed by generated reply prefix.
|
||||
/// </summary>
|
||||
public Dictionary<string, ServiceImport> Responses { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user