Compare commits
96 Commits
c30e67a69d
...
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 | |||
| b2707a7493 | |||
| 845441b32c | |||
| d1f22255d7 | |||
| a3b34fb16d | |||
| 0126234fa6 | |||
| 6cf11969f5 | |||
| 9fa2ba97b9 | |||
| ca7e12e753 | |||
| 5876ad7dfa | |||
| 98cbdbdeb8 | |||
| 348bec36b2 | |||
| 08bd34c529 | |||
| 0be321fa53 | |||
| 0a4e7a822f | |||
| 9e0df9b3d7 | |||
| 4de691c9c5 | |||
| 37575dc41c | |||
| e9c86c51c3 | |||
| 3445a055eb | |||
| ab805c883b | |||
| be1303c17b | |||
| f64b7103f4 | |||
| d8eadeb624 | |||
| 13443e7958 | |||
| 75ad411d83 | |||
| b9ad33d8bd | |||
| d132a0b0d1 | |||
| e724b3cc88 | |||
| 8877df45c8 | |||
| b5e1786ec2 | |||
| 95e9f0a92e | |||
| 246fc7ad87 | |||
| ced5062f50 | |||
| e4d275c929 | |||
| c9b55093a4 | |||
| 139b984992 | |||
| 571c595d0a | |||
| aeb60d3c43 | |||
| 338f44b07b | |||
| 5d9d1bebd5 | |||
| 76f8ccec2e | |||
| e094846665 | |||
| 8ad2172e3c | |||
| 4853409a40 | |||
| 0e252d6ccf | |||
| 1f122bf56f | |||
| bc308a4349 | |||
| 7fbffffd05 | |||
| 78b4bc2486 | |||
| 36b9dfa654 | |||
| 0c086522a4 | |||
| edf9ed770e | |||
| 615752cdc2 | |||
| 3f7d896a34 | |||
| 9972b74bc3 | |||
| a6be5e11ed | |||
| d2c04fcca5 | |||
| 5c608f07e3 | |||
| 2a75ee534a | |||
| fb19b50231 | |||
| 6941d9275b |
@@ -1,16 +1 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/Users/dohertj2/.dotnet/tools/slopwatch analyze -d . --hook",
|
||||
"timeout": 60000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
{"hooks":{}}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project ports the [NATS server](https://github.com/nats-io/nats-server) from Go to .NET 10 / C#. The Go reference implementation lives in `golang/nats-server/`. The .NET port lives at the repository root.
|
||||
|
||||
NATS is a high-performance publish-subscribe messaging system. It supports wildcards (`*` single token, `>` multi-token), queue groups for load balancing, request-reply, clustering (full-mesh routes, gateways, leaf nodes), and persistent streaming via JetStream.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
The solution file is `NatsDotNet.slnx`.
|
||||
|
||||
```bash
|
||||
# Build the solution
|
||||
dotnet build
|
||||
|
||||
# Run all tests
|
||||
dotnet test
|
||||
|
||||
# Run tests with verbose output
|
||||
dotnet test -v normal
|
||||
|
||||
# Run a single test project
|
||||
dotnet test tests/NATS.Server.Tests
|
||||
|
||||
# Run a specific test project
|
||||
dotnet test tests/NATS.Server.Core.Tests
|
||||
dotnet test tests/NATS.Server.JetStream.Tests
|
||||
|
||||
# Run a specific test by name
|
||||
dotnet test tests/NATS.Server.Core.Tests --filter "FullyQualifiedName~TestName"
|
||||
|
||||
# Run the NATS server (default port 4222)
|
||||
dotnet run --project src/NATS.Server.Host
|
||||
|
||||
# Run the NATS server on a custom port
|
||||
dotnet run --project src/NATS.Server.Host -- -p 14222
|
||||
|
||||
# Clean and rebuild
|
||||
dotnet clean && dotnet build
|
||||
```
|
||||
|
||||
## .NET Project Structure
|
||||
|
||||
```
|
||||
NatsDotNet.slnx # Solution file
|
||||
src/
|
||||
NATS.Server/ # Core server library
|
||||
NatsServer.cs # Server: listener, accept loop, shutdown
|
||||
NatsClient.cs # Per-connection client: read/write loops, sub tracking
|
||||
NatsOptions.cs # Server configuration (port, host, etc.)
|
||||
Protocol/
|
||||
NatsParser.cs # Protocol state machine (PUB, SUB, UNSUB, etc.)
|
||||
NatsProtocol.cs # Wire-level protocol writing (INFO, MSG, PING/PONG)
|
||||
Subscriptions/
|
||||
SubjectMatch.cs # Subject validation and wildcard matching
|
||||
SubList.cs # Trie-based subscription list with caching
|
||||
SubListResult.cs # Match result container (plain subs + queue groups)
|
||||
Subscription.cs # Subscription model (subject, sid, queue, client)
|
||||
NATS.Server.Host/ # Executable host app
|
||||
Program.cs # Entry point, CLI arg parsing (-p port)
|
||||
tests/
|
||||
NATS.Server.TestUtilities/ # Shared helpers, fixtures, parity tools (class library)
|
||||
NATS.Server.Core.Tests/ # Client, server, parser, config, subscriptions, protocol
|
||||
NATS.Server.Auth.Tests/ # Auth, accounts, permissions, JWT, NKeys
|
||||
NATS.Server.JetStream.Tests/ # JetStream API, streams, consumers, storage, cluster
|
||||
NATS.Server.Raft.Tests/ # RAFT consensus
|
||||
NATS.Server.Clustering.Tests/ # Routes, cluster topology, inter-server protocol
|
||||
NATS.Server.Gateways.Tests/ # Gateway connections, interest modes
|
||||
NATS.Server.LeafNodes.Tests/ # Leaf node connections, hub-spoke
|
||||
NATS.Server.Mqtt.Tests/ # MQTT protocol bridge
|
||||
NATS.Server.Monitoring.Tests/ # Monitor endpoints, events, system events
|
||||
NATS.Server.Transport.Tests/ # WebSocket, TLS, OCSP, IO
|
||||
NATS.E2E.Tests/ # End-to-end tests using NATS.Client.Core NuGet
|
||||
```
|
||||
|
||||
## Go Reference Commands
|
||||
|
||||
```bash
|
||||
# Build the Go reference server
|
||||
cd golang/nats-server && go build
|
||||
|
||||
# Run Go tests for a specific area
|
||||
cd golang/nats-server && go test -v -run TestName ./server/ -count=1 -timeout=30m
|
||||
|
||||
# Run all Go server tests (slow, ~30min)
|
||||
cd golang/nats-server && go test -v ./server/ -count=1 -timeout=30m
|
||||
```
|
||||
|
||||
## Architecture: NATS Server (Reference)
|
||||
|
||||
The Go source in `golang/nats-server/server/` is the authoritative reference. Key files by subsystem:
|
||||
|
||||
### Core Message Path
|
||||
- **`server.go`** — Server struct, startup lifecycle (`NewServer` → `Run` → `WaitForShutdown`), listener management
|
||||
- **`client.go`** (6700 lines) — Connection handling, `readLoop`/`writeLoop` goroutines, per-client subscription tracking, dynamic buffer sizing (512→65536 bytes), client types: `CLIENT`, `ROUTER`, `GATEWAY`, `LEAF`, `SYSTEM`
|
||||
- **`parser.go`** — Protocol state machine. Text protocol: `PUB`, `SUB`, `UNSUB`, `CONNECT`, `INFO`, `PING/PONG`, `MSG`. Extended: `HPUB/HMSG` (headers), `RPUB/RMSG` (routes). Control line limit: 4096 bytes. Default max payload: 1MB.
|
||||
- **`sublist.go`** — Trie-based subject matcher with wildcard support. Nodes have `psubs` (plain), `qsubs` (queue groups), special pointers for `*` and `>` wildcards. Results are cached with atomic generation IDs for invalidation.
|
||||
|
||||
### Authentication & Accounts
|
||||
- **`auth.go`** — Auth mechanisms: username/password, token, NKeys (Ed25519), JWT, external auth callout, LDAP
|
||||
- **`accounts.go`** (137KB) — Multi-tenant account isolation. Each account has its own `Sublist`, client set, and subject namespace. Supports exports/imports between accounts, service latency tracking.
|
||||
- **`jwt.go`**, **`nkey.go`** — JWT claims parsing and NKey validation
|
||||
|
||||
### Clustering
|
||||
- **`route.go`** — Full-mesh cluster routes. Route pooling (default 3 connections per peer). Account-specific dedicated routes. Protocol: `RS+`/`RS-` for subscribe propagation, `RMSG` for routed messages.
|
||||
- **`gateway.go`** (103KB) — Inter-cluster bridges. Interest-only mode optimizes traffic. Reply subject mapping (`_GR_.` prefix) avoids cross-cluster conflicts.
|
||||
- **`leafnode.go`** — Hub-and-spoke topology for edge deployments. Only subscribed subjects shared with hub. Loop detection via `$LDS.` prefix.
|
||||
|
||||
### JetStream (Persistence)
|
||||
- **`jetstream.go`** — Orchestration, API subject handlers (`$JS.API.*`)
|
||||
- **`stream.go`** (8000 lines) — Stream lifecycle, retention policies (Limits, Interest, WorkQueue), subject transforms, mirroring/sourcing
|
||||
- **`consumer.go`** — Stateful readers. Push vs pull delivery. Ack policies: None, All, Explicit. Redelivery tracking, priority groups.
|
||||
- **`filestore.go`** (337KB) — Block-based persistent storage with S2 compression, encryption (ChaCha20/AES-GCM), indexing
|
||||
- **`memstore.go`** — In-memory storage with hash-wheel TTL expiration
|
||||
- **`raft.go`** — RAFT consensus for clustered JetStream. Meta-cluster for metadata, per-stream/consumer RAFT groups.
|
||||
|
||||
### Configuration & Monitoring
|
||||
- **`opts.go`** — CLI flags + config file loading. CLI overrides config. Supports hot reload on signal.
|
||||
- **`monitor.go`** — HTTP endpoints: `/varz`, `/connz`, `/routez`, `/gatewayz`, `/jsz`, `/healthz`
|
||||
- **`conf/`** — Config file parser (custom format with includes)
|
||||
|
||||
### Internal Data Structures
|
||||
- **`server/avl/`** — AVL tree for sparse sequence sets (ack tracking)
|
||||
- **`server/stree/`** — Subject tree for per-subject state in streams
|
||||
- **`server/gsl/`** — Generic subject list, optimized trie
|
||||
- **`server/thw/`** — Time hash wheel for efficient TTL expiration
|
||||
|
||||
## Key Porting Considerations
|
||||
|
||||
**Concurrency model:** Go uses goroutines (one per connection readLoop + writeLoop). Map to async/await with `Task`-based I/O. Use `Channel<T>` or `Pipe` for producer-consumer patterns where Go uses channels.
|
||||
|
||||
**Locking:** Go `sync.RWMutex` maps to `ReaderWriterLockSlim`. Go `sync.Map` maps to `ConcurrentDictionary`. Go `atomic` operations map to `Interlocked` or `volatile`.
|
||||
|
||||
**Subject matching:** The `Sublist` trie is performance-critical. Every published message triggers a `Match()` call. Cache invalidation uses atomic generation counters.
|
||||
|
||||
**Protocol parsing:** The parser is a byte-by-byte state machine. In .NET, use `System.IO.Pipelines` for zero-copy parsing with `ReadOnlySequence<byte>`.
|
||||
|
||||
**Buffer management:** Go uses `[]byte` slices with pooling. Map to `ArrayPool<byte>` and `Memory<T>`/`Span<T>`.
|
||||
|
||||
**Compression:** NATS uses S2 (Snappy variant) for route/gateway compression. Use an equivalent .NET S2 library or IronSnappy.
|
||||
|
||||
**Ports:** Client=4222, Cluster=6222, Monitoring=8222, Leaf=5222, Gateway=7222.
|
||||
|
||||
## Message Flow Summary
|
||||
|
||||
```
|
||||
Client PUB → parser → permission check → Sublist.Match() →
|
||||
├─ Local subscribers: MSG to each (queue subs: pick one per group)
|
||||
├─ Cluster routes: RMSG to peers (who deliver to their locals)
|
||||
├─ Gateways: forward to interested remote clusters
|
||||
└─ JetStream: if subject matches a stream, store + deliver to consumers
|
||||
```
|
||||
|
||||
## NuGet Package Management
|
||||
|
||||
This solution uses **Central Package Management (CPM)** via `Directory.Packages.props` at the repo root. All package versions are defined centrally there.
|
||||
|
||||
- In `.csproj` files, use `<PackageReference Include="Foo" />` **without** a `Version` attribute
|
||||
- To add a new package: add a `<PackageVersion>` entry in `Directory.Packages.props`, then reference it without version in the project's csproj
|
||||
- To update a version: change it only in `Directory.Packages.props` — all projects pick it up automatically
|
||||
- Never specify `Version` on `<PackageReference>` in individual csproj files
|
||||
|
||||
## Logging
|
||||
|
||||
Use **Microsoft.Extensions.Logging** (`ILogger<T>`) for all logging throughout the server. Wire up **Serilog** as the logging provider in the host application.
|
||||
|
||||
- Inject `ILogger<T>` via constructor in all components (NatsServer, NatsClient, etc.)
|
||||
- Use **Serilog.Context.LogContext** to push contextual properties (client ID, remote endpoint, subscription subject) so they appear on all log entries within that scope
|
||||
- Use structured logging with message templates: `logger.LogInformation("Client {ClientId} subscribed to {Subject}", id, subject)` — never string interpolation
|
||||
- Log levels: `Trace` for protocol bytes, `Debug` for per-message flow, `Information` for lifecycle events (connect/disconnect), `Warning` for protocol violations, `Error` for unexpected failures
|
||||
|
||||
## Testing
|
||||
|
||||
- **xUnit 3** for test framework
|
||||
- **Shouldly** for assertions — use `value.ShouldBe(expected)`, `action.ShouldThrow<T>()`, etc. Do NOT use `Assert.*` from xUnit
|
||||
- **NSubstitute** for mocking/substitution when needed
|
||||
- Do **NOT** use FluentAssertions or Moq — these are explicitly excluded
|
||||
- Test project uses global `using Shouldly;`
|
||||
|
||||
## Porting Guidelines
|
||||
|
||||
- Use modern .NET 10 / C# 14 best practices (primary constructors, collection expressions, `field` keyword where stable, file-scoped namespaces, raw string literals, etc.)
|
||||
- Prefer `readonly record struct` for small value types over mutable structs
|
||||
- Use `required` properties and `init` setters for initialization-only state
|
||||
- Use pattern matching and switch expressions where they improve clarity
|
||||
- Prefer `System.Text.Json` source generators for JSON serialization
|
||||
- Use `ValueTask` where appropriate for hot-path async methods
|
||||
|
||||
## Agent Model Guidance
|
||||
|
||||
- **Sonnet** (`model: "sonnet"`) — use for simpler implementation tasks: straightforward file modifications, adding packages, converting assertions, boilerplate code
|
||||
- **Opus** (default) — use for complex tasks, architectural decisions, design work, tricky protocol logic, and code review
|
||||
- **Parallel subagents** — use where tasks are independent and don't touch the same files (e.g., converting test files in parallel, adding packages while updating docs)
|
||||
|
||||
## Documentation
|
||||
|
||||
Follow the documentation rules in [`documentation_rules.md`](documentation_rules.md) for all project documentation. Key points:
|
||||
|
||||
- Documentation lives in `Documentation/` with component subfolders (Protocol, Subscriptions, Server, Configuration, Operations)
|
||||
- Use `PascalCase.md` file names, always specify language on code blocks, use real code snippets (not invented examples)
|
||||
- Update documentation when code changes — see the trigger rules and component map in the rules file
|
||||
- Technical and direct tone, explain "why" not just "what", present tense
|
||||
|
||||
## Conventions
|
||||
|
||||
- Reference the Go implementation file and line when porting a subsystem
|
||||
- Maintain protocol compatibility — the .NET server must interoperate with existing NATS clients and Go servers in a cluster
|
||||
- Use the same configuration file format as the Go server (parsed by `conf/` package)
|
||||
- Match the Go server's monitoring JSON response shapes for tooling compatibility
|
||||
@@ -25,8 +25,12 @@ dotnet test -v normal
|
||||
# Run a single test project
|
||||
dotnet test tests/NATS.Server.Tests
|
||||
|
||||
# Run a specific test project
|
||||
dotnet test tests/NATS.Server.Core.Tests
|
||||
dotnet test tests/NATS.Server.JetStream.Tests
|
||||
|
||||
# Run a specific test by name
|
||||
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TestName"
|
||||
dotnet test tests/NATS.Server.Core.Tests --filter "FullyQualifiedName~TestName"
|
||||
|
||||
# Run the NATS server (default port 4222)
|
||||
dotnet run --project src/NATS.Server.Host
|
||||
@@ -58,13 +62,18 @@ src/
|
||||
NATS.Server.Host/ # Executable host app
|
||||
Program.cs # Entry point, CLI arg parsing (-p port)
|
||||
tests/
|
||||
NATS.Server.Tests/ # xUnit test project
|
||||
ParserTests.cs # Protocol parser tests
|
||||
SubjectMatchTests.cs # Subject validation & matching tests
|
||||
SubListTests.cs # Subscription list trie tests
|
||||
ClientTests.cs # Client-level protocol tests
|
||||
ServerTests.cs # Server pubsub/wildcard tests
|
||||
IntegrationTests.cs # End-to-end tests using NATS.Client.Core NuGet
|
||||
NATS.Server.TestUtilities/ # Shared helpers, fixtures, parity tools (class library)
|
||||
NATS.Server.Core.Tests/ # Client, server, parser, config, subscriptions, protocol
|
||||
NATS.Server.Auth.Tests/ # Auth, accounts, permissions, JWT, NKeys
|
||||
NATS.Server.JetStream.Tests/ # JetStream API, streams, consumers, storage, cluster
|
||||
NATS.Server.Raft.Tests/ # RAFT consensus
|
||||
NATS.Server.Clustering.Tests/ # Routes, cluster topology, inter-server protocol
|
||||
NATS.Server.Gateways.Tests/ # Gateway connections, interest modes
|
||||
NATS.Server.LeafNodes.Tests/ # Leaf node connections, hub-spoke
|
||||
NATS.Server.Mqtt.Tests/ # MQTT protocol bridge
|
||||
NATS.Server.Monitoring.Tests/ # Monitor endpoints, events, system events
|
||||
NATS.Server.Transport.Tests/ # WebSocket, TLS, OCSP, IO
|
||||
NATS.E2E.Tests/ # End-to-end tests using NATS.Client.Core NuGet
|
||||
```
|
||||
|
||||
## Go Reference Commands
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
<!-- NATS Client (integration tests) -->
|
||||
<PackageVersion Include="NATS.Client.Core" Version="2.7.2" />
|
||||
<PackageVersion Include="NATS.Client.JetStream" Version="2.7.2" />
|
||||
|
||||
<!-- MQTT Client (E2E tests) -->
|
||||
<PackageVersion Include="MQTTnet" Version="4.3.7.1207" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -24,9 +24,30 @@ public enum CommandType
|
||||
}
|
||||
```
|
||||
|
||||
### ParsedCommandView
|
||||
|
||||
`ParsedCommandView` is the byte-first parser result used on the hot path. It keeps protocol fields in byte-oriented storage and exposes payload as a `ReadOnlySequence<byte>` so single-segment bodies can flow through without an unconditional copy.
|
||||
|
||||
```csharp
|
||||
public readonly struct ParsedCommandView
|
||||
{
|
||||
public CommandType Type { get; init; }
|
||||
public string? Operation { get; init; }
|
||||
public ReadOnlyMemory<byte> Subject { get; init; }
|
||||
public ReadOnlyMemory<byte> ReplyTo { get; init; }
|
||||
public ReadOnlyMemory<byte> Queue { get; init; }
|
||||
public ReadOnlyMemory<byte> Sid { get; init; }
|
||||
public int MaxMessages { get; init; }
|
||||
public int HeaderSize { get; init; }
|
||||
public ReadOnlySequence<byte> Payload { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
`Subject`, `ReplyTo`, `Queue`, and `Sid` remain ASCII-encoded bytes until a caller explicitly materializes them. `Payload` stays sequence-backed until a caller asks for contiguous memory.
|
||||
|
||||
### ParsedCommand
|
||||
|
||||
`ParsedCommand` is a `readonly struct` that carries the result of a successful parse. Using a struct avoids a heap allocation per command on the fast path.
|
||||
`ParsedCommand` remains the compatibility shape for existing consumers. `TryParse` now delegates through `TryParseView` and materializes strings and contiguous payload memory in one adapter step instead of during every parse branch.
|
||||
|
||||
```csharp
|
||||
public readonly struct ParsedCommand
|
||||
@@ -46,15 +67,21 @@ public readonly struct ParsedCommand
|
||||
|
||||
Fields that do not apply to a given command type are left at their default values (`null` for strings, `0` for integers). `MaxMessages` uses `-1` as a sentinel meaning "unset" (relevant for UNSUB with no max). `HeaderSize` is set for HPUB/HMSG; `-1` indicates no headers. `Payload` carries the raw body bytes for PUB/HPUB, and the raw JSON bytes for CONNECT/INFO.
|
||||
|
||||
## TryParse
|
||||
## TryParseView and TryParse
|
||||
|
||||
`TryParse` is the main entry point. It is called by the read loop after each `PipeReader.ReadAsync` completes.
|
||||
`TryParseView` is the byte-oriented parser entry point. It is called by hot-path consumers such as `NatsClient.ProcessCommandsAsync` when they want to defer materialization.
|
||||
|
||||
```csharp
|
||||
internal bool TryParseView(ref ReadOnlySequence<byte> buffer, out ParsedCommandView command)
|
||||
```
|
||||
|
||||
`TryParse` remains the compatibility entry point for existing call sites and tests:
|
||||
|
||||
```csharp
|
||||
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
|
||||
```
|
||||
|
||||
The method returns `true` and advances `buffer` past the consumed bytes when a complete command is available. It returns `false` — leaving `buffer` unchanged — when more data is needed. The caller must call `TryParse` in a loop until it returns `false`, then call `PipeReader.AdvanceTo` to signal how far the buffer was consumed.
|
||||
Both methods return `true` and advance `buffer` past the consumed bytes when a complete command is available. They return `false` — leaving `buffer` unchanged — when more data is needed. The caller must call the parser in a loop until it returns `false`, then call `PipeReader.AdvanceTo` to signal how far the buffer was consumed.
|
||||
|
||||
If the parser detects a malformed command it throws `ProtocolViolationException`, which the read loop catches to close the connection.
|
||||
|
||||
@@ -158,9 +185,9 @@ The two-character pairs are: `p+i` = PING, `p+o` = PONG, `p+u` = PUB, `h+p` = HP
|
||||
|
||||
PUB and HPUB require a payload body that follows the control line. The parser handles split reads — where the TCP segment boundary falls inside the payload — through an `_awaitingPayload` state flag.
|
||||
|
||||
**Phase 1 — control line:** The parser reads the control line up to `\r\n`, extracts the subject, optional reply-to, and payload size(s), then stores these in private fields (`_pendingSubject`, `_pendingReplyTo`, `_expectedPayloadSize`, `_pendingHeaderSize`, `_pendingType`) and sets `_awaitingPayload = true`. It then immediately calls `TryReadPayload` to attempt phase 2.
|
||||
**Phase 1 — control line:** The parser reads the control line up to `\r\n`, extracts the subject, optional reply-to, and payload size(s), then stores these in private fields (`_pendingSubject`, `_pendingReplyTo`, `_expectedPayloadSize`, `_pendingHeaderSize`, `_pendingType`) and sets `_awaitingPayload = true`. The pending subject and reply values are held as byte-oriented state, not strings. It then immediately calls `TryReadPayload` to attempt phase 2.
|
||||
|
||||
**Phase 2 — payload read:** `TryReadPayload` checks whether `buffer.Length >= _expectedPayloadSize + 2` (the `+ 2` accounts for the trailing `\r\n`). If enough data is present, the payload bytes are copied to a new `byte[]`, the trailing `\r\n` is verified, the `ParsedCommand` is constructed, and `_awaitingPayload` is reset to `false`. If not enough data is present, `TryReadPayload` returns `false` and `_awaitingPayload` remains `true`.
|
||||
**Phase 2 — payload read:** `TryReadPayload` checks whether `buffer.Length >= _expectedPayloadSize + 2` (the `+ 2` accounts for the trailing `\r\n`). If enough data is present, the parser slices the payload as a `ReadOnlySequence<byte>`, verifies the trailing `\r\n`, constructs a `ParsedCommandView`, and resets `_awaitingPayload` to `false`. If not enough data is present, `TryReadPayload` returns `false` and `_awaitingPayload` remains `true`.
|
||||
|
||||
On the next call to `TryParse`, the check at the top of the method routes straight to `TryReadPayload` without re-parsing the control line:
|
||||
|
||||
@@ -171,6 +198,20 @@ if (_awaitingPayload)
|
||||
|
||||
This means the parser correctly handles payloads that arrive across multiple `PipeReader.ReadAsync` completions without buffering the control line a second time.
|
||||
|
||||
## Materialization Boundaries
|
||||
|
||||
The parser now has explicit materialization boundaries:
|
||||
|
||||
- `TryParseView` keeps payloads sequence-backed and leaves token fields as bytes.
|
||||
- `ParsedCommandView.Materialize()` converts byte fields to strings and converts multi-segment payloads to a standalone `byte[]`.
|
||||
- `NatsClient` consumes `ParsedCommandView` directly for the `PUB` and `HPUB` hot path, only decoding subject and reply strings at the routing and permission-check boundary.
|
||||
- `CONNECT` and `INFO` now keep their JSON payload as a slice of the original control-line sequence until a consumer explicitly materializes it.
|
||||
|
||||
Payload copying is still intentional in two places:
|
||||
|
||||
- when a multi-segment payload must become contiguous for a consumer using `ReadOnlyMemory<byte>`
|
||||
- when compatibility callers continue to use `TryParse` and require a materialized `ParsedCommand`
|
||||
|
||||
## Zero-Allocation Argument Splitting
|
||||
|
||||
`SplitArgs` splits the argument portion of a control line into token ranges without allocating. The caller `stackalloc`s a `Span<Range>` sized to the maximum expected argument count for the command, then passes it to `SplitArgs`:
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
+13
-1
@@ -4,7 +4,19 @@
|
||||
<Project Path="src/NATS.Server/NATS.Server.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/NATS.Server.Tests/NATS.Server.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj" />
|
||||
<Project Path="tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Mqtt.Tests/NATS.Server.Mqtt.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.LeafNodes.Tests/NATS.Server.LeafNodes.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Raft.Tests/NATS.Server.Raft.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Monitoring.Tests/NATS.Server.Monitoring.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Auth.Tests/NATS.Server.Auth.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj" />
|
||||
<Project Path="tests/NATS.E2E.Tests/NATS.E2E.Tests.csproj" />
|
||||
<Project Path="tests/NATS.E2E.Cluster.Tests/NATS.E2E.Cluster.Tests.csproj" />
|
||||
<Project Path="tests/NATS.Server.Benchmark.Tests/NATS.Server.Benchmark.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# NATS Go Server — Reference Benchmark Numbers
|
||||
|
||||
Typical throughput and latency figures for the Go NATS server, collected from official documentation and community benchmarks. These serve as performance targets for the .NET port.
|
||||
|
||||
## Test Environment
|
||||
|
||||
Official NATS docs benchmarks were run on an Apple M4 (10 cores: 4P + 6E, 16 GB RAM). Numbers will vary by hardware — treat these as order-of-magnitude targets, not exact goals.
|
||||
|
||||
---
|
||||
|
||||
## Core NATS — Pub/Sub Throughput
|
||||
|
||||
All figures use the `nats bench` tool with default 16-byte messages unless noted.
|
||||
|
||||
### Single Publisher (no subscribers)
|
||||
|
||||
| Messages | Payload | Throughput | Latency |
|
||||
|----------|---------|------------|---------|
|
||||
| 1M | 16 B | 14,786,683 msgs/sec (~226 MiB/s) | 0.07 us |
|
||||
|
||||
### Publisher + Subscriber (1:1)
|
||||
|
||||
| Messages | Payload | Throughput (each side) | Latency |
|
||||
|----------|---------|------------------------|---------|
|
||||
| 1M | 16 B | ~4,927,000 msgs/sec (~75 MiB/s) | 0.20 us |
|
||||
| 100K | 16 KB | ~228,000 msgs/sec (~3.5 GiB/s) | 4.3 us |
|
||||
|
||||
### Fan-Out (1 Publisher : N Subscribers)
|
||||
|
||||
| Subscribers | Payload | Per-Subscriber Rate | Aggregate | Latency |
|
||||
|-------------|---------|---------------------|-----------|---------|
|
||||
| 4 | 128 B | ~1,010,000 msgs/sec | 4,015,923 msgs/sec (~490 MiB/s) | ~1.0 us |
|
||||
|
||||
### Multi-Publisher / Multi-Subscriber (N:M)
|
||||
|
||||
| Config | Payload | Pub Aggregate | Sub Aggregate | Pub Latency | Sub Latency |
|
||||
|--------|---------|---------------|---------------|-------------|-------------|
|
||||
| 4P x 4S | 128 B | 1,080,144 msgs/sec (~132 MiB/s) | 4,323,201 msgs/sec (~528 MiB/s) | 3.7 us | 0.93 us |
|
||||
|
||||
---
|
||||
|
||||
## Core NATS — Request/Reply Latency
|
||||
|
||||
| Config | Payload | Throughput | Avg Latency |
|
||||
|--------|---------|------------|-------------|
|
||||
| 1 client, 1 service | 128 B | 19,659 msgs/sec | 50.9 us |
|
||||
| 50 clients, 2 services | 16 B | 132,438 msgs/sec | ~370 us |
|
||||
|
||||
---
|
||||
|
||||
## Core NATS — Tail Latency Under Load
|
||||
|
||||
From the Brave New Geek latency benchmarks (loopback, request/reply):
|
||||
|
||||
| Payload | Rate | p99.99 | p99.9999 | Notes |
|
||||
|---------|------|--------|----------|-------|
|
||||
| 256 B | 3,000 req/s | sub-ms | sub-ms | Minimal load |
|
||||
| 1 KB | 3,000 req/s | sub-ms | ~1.2 ms | |
|
||||
| 5 KB | 2,000 req/s | sub-ms | ~1.2 ms | |
|
||||
| 1 KB | 20,000 req/s (25 conns) | elevated | ~90 ms | Concurrent load |
|
||||
| 1 MB | 100 req/s | ~214 ms | — | Large payload tail |
|
||||
|
||||
A protocol parser optimization improved 5 KB latencies by ~30% and 1 MB latencies by ~90% up to p90.
|
||||
|
||||
---
|
||||
|
||||
## JetStream — Publication
|
||||
|
||||
| Mode | Payload | Storage | Throughput | Latency |
|
||||
|------|---------|---------|------------|---------|
|
||||
| Synchronous | 16 B | Memory | 35,734 msgs/sec (~558 KiB/s) | 28.0 us |
|
||||
| Batch (1000 msgs) | 16 B | Memory | 627,430 msgs/sec (~9.6 MiB/s) | 1.6 us |
|
||||
| Async | 128 B | File | 403,828 msgs/sec (~49 MiB/s) | 2.5 us |
|
||||
|
||||
---
|
||||
|
||||
## JetStream — Consumption
|
||||
|
||||
| Mode | Clients | Throughput | Latency |
|
||||
|------|---------|------------|---------|
|
||||
| Ordered ephemeral consumer | 1 | 1,201,540 msgs/sec (~147 MiB/s) | 0.83 us |
|
||||
| Durable consumer (callback) | 4 | 290,438 msgs/sec (~36 MiB/s) | 13.7 us |
|
||||
| Durable consumer fetch (no ack) | 2 | 1,128,932 msgs/sec (~138 MiB/s) | 1.76 us |
|
||||
| Direct sync get | 1 | 33,244 msgs/sec (~4.1 MiB/s) | 30.1 us |
|
||||
| Batched get | 2 | 1,000,898 msgs/sec (~122 MiB/s) | — |
|
||||
|
||||
---
|
||||
|
||||
## JetStream — Key-Value Store
|
||||
|
||||
| Operation | Clients | Payload | Throughput | Latency |
|
||||
|-----------|---------|---------|------------|---------|
|
||||
| Sync put | 1 | 128 B | 30,067 msgs/sec (~3.7 MiB/s) | 33.3 us |
|
||||
| Get (randomized keys) | 16 | 128 B | 102,844 msgs/sec (~13 MiB/s) | ~153 us |
|
||||
|
||||
---
|
||||
|
||||
## Resource Usage
|
||||
|
||||
| Scenario | RSS Memory |
|
||||
|----------|------------|
|
||||
| Core NATS at 2M msgs/sec (1 pub + 1 sub) | ~11 MB |
|
||||
| JetStream production (recommended minimum) | 4 CPU cores, 8 GiB RAM |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [NATS Bench — Official Docs](https://docs.nats.io/using-nats/nats-tools/nats_cli/natsbench)
|
||||
- [NATS Latency Test Framework](https://github.com/nats-io/latency-tests)
|
||||
- [Benchmarking Message Queue Latency — Brave New Geek](https://bravenewgeek.com/benchmarking-message-queue-latency/)
|
||||
- [NATS CLI Benchmark Blog](https://nats.io/blog/cli-benchmark/)
|
||||
@@ -0,0 +1,295 @@
|
||||
# Go vs .NET NATS Server — Benchmark Comparison
|
||||
|
||||
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, Release build (server GC, tiered PGO enabled), Go toolchain installed, Go reference server built from `golang/nats-server/`.
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## Core NATS — Pub/Sub Throughput
|
||||
|
||||
### Single Publisher (no subscribers)
|
||||
|
||||
| Payload | Go msg/s | Go MB/s | .NET msg/s | .NET MB/s | Ratio (.NET/Go) |
|
||||
|---------|----------|---------|------------|-----------|-----------------|
|
||||
| 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 | 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 | 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 | 1,870,855 | 228.4 | 1,892,631 | 231.0 | **1.01x** |
|
||||
|
||||
---
|
||||
|
||||
## Core NATS — Request/Reply Latency
|
||||
|
||||
### Single Client, Single Service
|
||||
|
||||
| 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 |
|
||||
|---------|----------|------------|-------|
|
||||
| 16 B | 30,563 | 26,178 | 0.86x |
|
||||
|
||||
---
|
||||
|
||||
## JetStream — Publication
|
||||
|
||||
| Mode | Payload | Storage | Go msg/s | .NET msg/s | Ratio (.NET/Go) |
|
||||
|------|---------|---------|----------|------------|-----------------|
|
||||
| Synchronous | 16 B | Memory | 16,982 | 14,514 | 0.85x |
|
||||
| Async (batch) | 128 B | File | 174,421 | 85,394 | 0.49x |
|
||||
|
||||
---
|
||||
|
||||
## JetStream — Consumption
|
||||
|
||||
| Mode | Go msg/s | .NET msg/s | Ratio (.NET/Go) |
|
||||
|------|----------|------------|-----------------|
|
||||
| Ordered ephemeral consumer | 786,681 | 346,162 | 0.44x |
|
||||
| Durable consumer fetch | 711,203 | 542,250 | 0.76x |
|
||||
|
||||
---
|
||||
|
||||
## 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 | 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. **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:
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 20 | **Per-subscriber flush signal in fan-out** — each `SendMessage` called `_flushSignal.Writer.TryWrite(0)` independently; for 1:4 fan-out, 4 channel writes + 4 write-loop wakeups per published message | Split `SendMessage` into `SendMessageNoFlush` + `SignalFlush`; `ProcessMessage` collects unique clients in `[ThreadStatic] HashSet<INatsClient>` (Go's `pcd` pattern), one flush signal per unique client after fan-out | Reduces channel writes from N to unique-client-count per publish |
|
||||
| 21 | **Per-fetch `CompiledFilter` allocation** — `CompiledFilter.FromConfig(consumer.Config)` called on every fetch request, allocating a new filter object each time | Cached `CompiledFilter` on `ConsumerHandle` with staleness detection (reference + value check on filter config fields); reused across fetches | Eliminates per-fetch filter allocation |
|
||||
| 22 | **Per-message string interpolation in ack reply** — `$"$JS.ACK.{stream}.{consumer}.1.{seq}.{deliverySeq}.{ts}.{pending}"` allocated intermediate strings and boxed numeric types on every delivery | Pre-compute `$"$JS.ACK.{stream}.{consumer}.1."` prefix before loop; use `stackalloc char[]` + `TryFormat` for numeric suffix — zero intermediate allocations | Eliminates 4+ string allocs per delivered message |
|
||||
| 23 | **Per-fetch `List<StoredMessage>` allocation** — `new List<StoredMessage>(batch)` allocated on every `FetchAsync` call | `[ThreadStatic]` reusable list with `.Clear()` + capacity growth; `PullFetchBatch` snapshots via `.ToArray()` for safe handoff | Eliminates per-fetch list allocation |
|
||||
|
||||
### Round 5: Non-blocking ConsumeAsync (ordered + durable consumers)
|
||||
|
||||
One root cause was identified and fixed in the MSG.NEXT request handling path:
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 19 | **Synchronous blocking in DeliverPullFetchMessages** — `FetchAsync(...).GetAwaiter().GetResult()` blocked the client's read loop for the full `expires` timeout (30s). With `batch=1000` and only 5 messages available, the fetch polled for message 6 indefinitely. No messages were delivered until the timeout fired, causing the client to receive 0 messages before its own timeout. | Split into two paths: `noWait`/no-expires uses synchronous fetch (existing behavior for `FetchAsync` client); `expires > 0` spawns `DeliverPullFetchMessagesAsync` background task that delivers messages incrementally without blocking the read loop, with idle heartbeat support | Enables `ConsumeAsync` for both ordered and durable consumers; ordered consumer: 99K msg/s (0.64x Go) |
|
||||
|
||||
### Round 4: Per-Client Direct Write Buffer (pub/sub + fan-out + multi pub/sub)
|
||||
|
||||
Four optimizations were implemented in the message delivery hot path:
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 15 | **Per-message channel overhead** — each `SendMessage` call went through `Channel<OutboundData>.TryWrite`, incurring lock contention and memory barriers | Replaced channel-based message delivery with per-client `_directBuf` byte array under `SpinLock`; messages written directly to contiguous buffer | Eliminates channel overhead per delivery |
|
||||
| 16 | **Per-message heap allocation for MSG header** — `_outboundBufferPool.RentBuffer()` allocated a pooled `byte[]` for each MSG header | Replaced with `stackalloc byte[512]` — MSG header formatted entirely on the stack, then copied into `_directBuf` | Zero heap allocations per delivery |
|
||||
| 17 | **Per-message socket write** — write loop issued one `SendAsync` per channel item, even with coalescing | Double-buffer swap: write loop swaps `_directBuf` ↔ `_writeBuf` under `SpinLock`, then writes the entire batch in a single `SendAsync`; zero allocation on swap | Single syscall per batch, zero-copy buffer reuse |
|
||||
| 18 | **Separate wake channels** — `SendMessage` and `WriteProtocol` used different signaling paths | Unified on `_flushSignal` channel (bounded capacity 1, DropWrite); both paths signal the same channel, write loop drains both `_directBuf` and `_outbound` on each wake | Single wait point, no missed wakes |
|
||||
|
||||
### Round 3: Outbound Write Path (pub/sub + fan-out + fetch)
|
||||
|
||||
Three root causes were identified and fixed in the message delivery hot path:
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 12 | **Per-message `.ToArray()` allocation in SendMessage** — `owner.Memory[..pos].ToArray()` created a new `byte[]` for every MSG delivered to every subscriber | Replaced `IMemoryOwner` rent/copy/dispose with direct `byte[]` from pool; write loop returns buffers after writing | Eliminates 1 heap alloc per delivery (4 per fan-out message) |
|
||||
| 13 | **Per-message `WriteAsync` in write loop** — each queued message triggered a separate `_stream.WriteAsync()` system call | Added 64KB coalesce buffer; drain all pending messages into contiguous buffer, single `WriteAsync` per batch | Reduces syscalls from N to 1 per batch |
|
||||
| 14 | **Profiling `Stopwatch` on every message** — `Stopwatch.StartNew()` ran unconditionally in `ProcessMessage` and `StreamManager.Capture` even for non-JetStream messages | Removed profiling instrumentation from hot path | Eliminates ~200ns overhead per message |
|
||||
|
||||
### Round 2: FileStore AppendAsync Hot Path
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 6 | **Async state machine overhead** — `AppendAsync` was `async ValueTask<ulong>` but never actually awaited | Changed to synchronous `ValueTask<ulong>` returning `ValueTask.FromResult(_last)` | Eliminates Task state machine allocation |
|
||||
| 7 | **Double payload copy** — `TransformForPersist` allocated `byte[]` then `payload.ToArray()` created second copy for `StoredMessage` | Reuse `TransformForPersist` result directly for `StoredMessage.Payload` when no transform needed (`_noTransform` flag) | Eliminates 1 `byte[]` alloc per message |
|
||||
| 8 | **Unnecessary TTL work per publish** — `ExpireFromWheel()` and `RegisterTtl()` called on every write even when `MaxAge=0` | Guarded both with `_options.MaxAgeMs > 0` check (matches Go: `filestore.go:4701`) | Eliminates hash wheel overhead when TTL not configured |
|
||||
| 9 | **Per-message MsgBlock cache allocation** — `WriteAt` created `new MessageRecord` for `_cache` on every write | Removed eager cache population; reads now decode from pending buffer or disk | Eliminates 1 object alloc per message |
|
||||
| 10 | **Contiguous write buffer** — `MsgBlock._pendingWrites` was `List<byte[]>` with per-message `byte[]` allocations | Replaced with single contiguous `_pendingBuf` byte array; `MessageRecord.EncodeTo` writes directly into it | Eliminates per-message `byte[]` encoding alloc; single `RandomAccess.Write` per flush |
|
||||
| 11 | **Pending buffer read path** — `MsgBlock.Read()` flushed pending writes to disk before reading | Added in-memory read from `_pendingBuf` when data is still in the buffer | Avoids unnecessary disk flush on read-after-write |
|
||||
|
||||
### Round 1: FileStore/StreamManager Layer
|
||||
|
||||
| # | Root Cause | Fix | Impact |
|
||||
|---|-----------|-----|--------|
|
||||
| 1 | **Per-message synchronous disk I/O** — `MsgBlock.WriteAt()` called `RandomAccess.Write()` on every message | Added write buffering in MsgBlock + background flush loop in FileStore (Go's `flushLoop` pattern: coalesce 16KB or 8ms) | Eliminates per-message syscall overhead |
|
||||
| 2 | **O(n) `GetStateAsync` per publish** — `_messages.Keys.Min()` and `_messages.Values.Sum()` on every publish for MaxMsgs/MaxBytes checks | Added incremental `_messageCount`, `_totalBytes`, `_firstSeq` fields updated in all mutation paths; `GetStateAsync` is now O(1) | Eliminates O(n) scan per publish |
|
||||
| 3 | **Unnecessary `LoadAsync` after every append** — `StreamManager.Capture` reloaded the just-stored message even when no mirrors/sources were configured | Made `LoadAsync` conditional on mirror/source replication being configured | Eliminates redundant disk read per publish |
|
||||
| 4 | **Redundant `PruneExpiredMessages` per publish** — called before every publish even when `MaxAge=0`, and again inside `EnforceRuntimePolicies` | Guarded with `MaxAgeMs > 0` check; removed the pre-publish call (background expiry timer handles it) | Eliminates O(n) scan per publish |
|
||||
| 5 | **`PrunePerSubject` loading all messages per publish** — `EnforceRuntimePolicies` → `PrugePerSubject` called `ListAsync().GroupBy()` even when `MaxMsgsPer=0` | Guarded with `MaxMsgsPer > 0` check | Eliminates O(n) scan per publish |
|
||||
|
||||
Additional fixes: SHA256 envelope bypass for unencrypted/uncompressed stores, RAFT propose skip for single-replica streams.
|
||||
|
||||
### What would further close the gap
|
||||
|
||||
| Change | Expected Impact | Go Reference |
|
||||
|--------|----------------|-------------|
|
||||
| **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,116 @@
|
||||
# E2E Cluster & RAFT Tests Design
|
||||
|
||||
## Goal
|
||||
|
||||
Create a new test project `NATS.E2E.Cluster.Tests` with comprehensive end-to-end tests for cluster resilience, RAFT consensus, JetStream cluster replication, gateway failover, and leaf node failover. All tests run against real multi-process server instances (not in-memory mocks).
|
||||
|
||||
## Architecture
|
||||
|
||||
Single new xUnit 3 test project with shared fixtures per topology. Separated from the existing fast `NATS.E2E.Tests` suite (78 tests, ~6s) because cluster failover tests are inherently slower (30s+ timeouts, node kill/restart cycles).
|
||||
|
||||
**Dependencies:** `NATS.Client.Core`, `NATS.NKeys`, `xUnit 3`, `Shouldly` — same as existing E2E project. No new NuGet packages.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tests/NATS.E2E.Cluster.Tests/
|
||||
NATS.E2E.Cluster.Tests.csproj
|
||||
Infrastructure/
|
||||
NatsServerProcess.cs (copied from NATS.E2E.Tests, standalone utility)
|
||||
ThreeNodeClusterFixture.cs (3-node full-mesh cluster)
|
||||
JetStreamClusterFixture.cs (3-node cluster with JetStream enabled)
|
||||
GatewayPairFixture.cs (2 independent clusters connected by gateway)
|
||||
HubLeafFixture.cs (1 hub + 1 leaf node)
|
||||
ClusterResilienceTests.cs (4 tests)
|
||||
RaftConsensusTests.cs (4 tests)
|
||||
JetStreamClusterTests.cs (4 tests)
|
||||
GatewayFailoverTests.cs (2 tests)
|
||||
LeafNodeFailoverTests.cs (2 tests)
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
### ThreeNodeClusterFixture
|
||||
- 3-node full-mesh cluster with monitoring enabled
|
||||
- Each node: client port, cluster port, monitoring port (all ephemeral)
|
||||
- `pool_size: 1` for deterministic routing
|
||||
- Readiness: polls `/routez` on all nodes until `NumRoutes >= 2`
|
||||
- Used by: ClusterResilienceTests
|
||||
|
||||
### JetStreamClusterFixture
|
||||
- Extends ThreeNodeClusterFixture pattern with `jetstream { store_dir }` config
|
||||
- Temp store dirs per node, cleaned up on dispose
|
||||
- Used by: RaftConsensusTests, JetStreamClusterTests
|
||||
|
||||
### GatewayPairFixture
|
||||
- 2 servers in separate "clusters" connected by gateway
|
||||
- Readiness: polls `/gatewayz` until `NumGateways >= 1` on both
|
||||
- Used by: GatewayFailoverTests
|
||||
|
||||
### HubLeafFixture
|
||||
- 1 hub server + 1 leaf server with solicited connection
|
||||
- Readiness: polls hub's `/leafz` until `NumLeafs >= 1`
|
||||
- Used by: LeafNodeFailoverTests
|
||||
|
||||
## KillAndRestart Primitive
|
||||
|
||||
Core infrastructure for all failover tests. Each fixture tracks `NatsServerProcess[]` by index.
|
||||
|
||||
- `KillNode(int index)` — kills the process, waits for exit
|
||||
- `RestartNode(int index)` — starts new process on same ports/config
|
||||
- `WaitForFullMesh()` — polls `/routez` until topology restored
|
||||
|
||||
Ports are pre-allocated and reused across kill/restart cycles so cluster config remains valid.
|
||||
|
||||
## Test Cases (16 total)
|
||||
|
||||
### ClusterResilienceTests.cs (ThreeNodeClusterFixture)
|
||||
|
||||
| Test | Validates |
|
||||
|------|-----------|
|
||||
| `NodeDies_TrafficReroutesToSurvivors` | Kill node 2, pub on node 0, sub on node 1 — messages delivered |
|
||||
| `NodeRejoins_SubscriptionsPropagateAgain` | Kill node 2, restart, verify new subs on node 2 receive messages from node 0 |
|
||||
| `AllRoutesReconnect_AfterNodeRestart` | Kill node 1, restart, poll `/routez` until full mesh restored |
|
||||
| `QueueGroup_NodeDies_RemainingMembersDeliver` | Queue subs on nodes 1+2, kill node 2, node 1 delivers all |
|
||||
|
||||
### RaftConsensusTests.cs (JetStreamClusterFixture)
|
||||
|
||||
| Test | Validates |
|
||||
|------|-----------|
|
||||
| `LeaderElection_ClusterFormsLeader` | Create R3 stream, verify one node reports as leader |
|
||||
| `LeaderDies_NewLeaderElected` | Identify leader, kill it, verify new leader elected |
|
||||
| `LogReplication_AllReplicasHaveData` | Publish to R3 stream, verify all replicas report same count |
|
||||
| `LeaderRestart_RejoinsAsFollower` | Kill leader, wait for new, restart old, verify catchup |
|
||||
|
||||
### JetStreamClusterTests.cs (JetStreamClusterFixture)
|
||||
|
||||
| Test | Validates |
|
||||
|------|-----------|
|
||||
| `R3Stream_CreateAndPublish_ReplicatedAcrossNodes` | Create R3 stream, publish, verify replicas on 3 nodes |
|
||||
| `R3Stream_NodeDies_PublishContinues` | Kill replica, continue publishing, verify on survivors |
|
||||
| `Consumer_NodeDies_PullContinuesOnSurvivor` | Pull consumer, kill its node, verify consumer works elsewhere |
|
||||
| `R3Stream_Purge_ReplicatedAcrossNodes` | Purge stream, verify all replicas report 0 |
|
||||
|
||||
### GatewayFailoverTests.cs (GatewayPairFixture)
|
||||
|
||||
| Test | Validates |
|
||||
|------|-----------|
|
||||
| `Gateway_Disconnect_Reconnects` | Kill gateway-B, restart, verify messaging resumes |
|
||||
| `Gateway_InterestUpdated_AfterReconnect` | Sub on B, kill B, restart, resub, verify delivery from A |
|
||||
|
||||
### LeafNodeFailoverTests.cs (HubLeafFixture)
|
||||
|
||||
| Test | Validates |
|
||||
|------|-----------|
|
||||
| `Leaf_Disconnect_ReconnectsToHub` | Kill leaf, restart, verify `/leafz` shows reconnection |
|
||||
| `Leaf_HubRestart_LeafReconnects` | Kill hub, restart, verify leaf reconnects and messaging resumes |
|
||||
|
||||
## Timeout Strategy
|
||||
|
||||
- Per-test: 30-second `CancellationTokenSource`
|
||||
- Fixture readiness (full mesh, gateway, leaf): 30-second timeout, 200ms polling
|
||||
- Node restart wait: reuse `WaitForTcpReadyAsync()` + topology readiness checks
|
||||
|
||||
## Port Allocation
|
||||
|
||||
Same `AllocateFreePort()` pattern as existing E2E infrastructure. Each fixture allocates all ports upfront and reuses them across kill/restart cycles.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
# E2E Test Full Gap Coverage Design
|
||||
|
||||
**Date:** 2026-03-12
|
||||
**Scope:** All 3 priority tiers from `e2e_gaps.md` (~45-55 new tests)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Multi-server fixtures:** Real multi-process only where required (cluster, leaf, gateway). Single server with feature flags for MQTT and WebSocket.
|
||||
- **Gateway topology:** Minimal two-server (not full multi-cluster).
|
||||
- **Shutdown/drain:** Both client drain and server shutdown tests.
|
||||
- **MQTT client:** MQTTnet NuGet package.
|
||||
- **WebSocket client:** Built-in `System.Net.WebSockets.ClientWebSocket` speaking raw NATS protocol.
|
||||
|
||||
## New Fixtures
|
||||
|
||||
| Fixture | Servers | Config |
|
||||
|---------|---------|--------|
|
||||
| `ClusterFixture` | 3 `NatsServerProcess` instances | Route config pointing at each other |
|
||||
| `LeafNodeFixture` | 2 instances (hub + leaf) | Leaf config pointing at hub |
|
||||
| `GatewayFixture` | 2 standalone instances | Gateway config connecting them |
|
||||
| `MqttServerFixture` | 1 instance | MQTT port enabled |
|
||||
| `WebSocketServerFixture` | 1 instance | WebSocket port enabled |
|
||||
|
||||
Existing `MonitorServerFixture` is already created but unused — use as-is.
|
||||
|
||||
## New Test Files
|
||||
|
||||
### 1. `MonitoringTests.cs` (existing `MonitorServerFixture`)
|
||||
|
||||
- `/varz` — returns JSON with server_name, version, connections
|
||||
- `/connz` — reflects connected client count
|
||||
- `/healthz` — returns 200 OK
|
||||
|
||||
### 2. `HeaderTests.cs` (existing `NatsServerFixture`)
|
||||
|
||||
- Publish with headers, receive with headers intact
|
||||
- Multiple headers on a single message
|
||||
- Empty header value round-trip
|
||||
|
||||
### 3. `ShutdownDrainTests.cs` (own server per test, no shared fixture)
|
||||
|
||||
- Client drain completes in-flight messages then disconnects
|
||||
- Server kill mid-connection — client detects disconnection gracefully
|
||||
|
||||
### 4. `ClusterTests.cs` (new `ClusterFixture`)
|
||||
|
||||
- Message published on node A received by subscriber on node B
|
||||
- Subscriber on node C receives after joining mid-stream
|
||||
- Queue group across cluster nodes delivers once total
|
||||
|
||||
### 5. `LeafNodeTests.cs` (new `LeafNodeFixture`)
|
||||
|
||||
- Message published on hub received by leaf subscriber
|
||||
- Message published on leaf received by hub subscriber
|
||||
- Only subscribed subjects propagate to hub
|
||||
|
||||
### 6. `GatewayTests.cs` (new `GatewayFixture`)
|
||||
|
||||
- Message crosses gateway from server A to server B
|
||||
- No cross-delivery when no interest on remote side
|
||||
|
||||
### 7. `MqttTests.cs` (new `MqttServerFixture`)
|
||||
|
||||
- MQTT subscribe → NATS publish → MQTT receives
|
||||
- MQTT publish → NATS subscribe → NATS receives
|
||||
- MQTT QoS 0 and QoS 1 delivery
|
||||
|
||||
### 8. `WebSocketTests.cs` (new `WebSocketServerFixture`)
|
||||
|
||||
- Connect via WebSocket, subscribe, receive message
|
||||
- Pub/sub round-trip over WebSocket
|
||||
|
||||
## Additions to Existing `JetStreamTests.cs`
|
||||
|
||||
- Push consumer (server-initiated delivery)
|
||||
- AckAll policy
|
||||
- AckNone policy
|
||||
- Interest retention
|
||||
- WorkQueue retention
|
||||
- Ordered consumer
|
||||
- Stream mirroring
|
||||
- Stream sourcing
|
||||
|
||||
## New File: `AdvancedTests.cs`
|
||||
|
||||
- JWT authentication (inline server with JWT config)
|
||||
- Account imports/exports (cross-account service call)
|
||||
- Subject transforms
|
||||
- Config file loading (full config file, verify behavior)
|
||||
- System events (`$SYS.>` subscription, detect connect event)
|
||||
- Max connections enforcement
|
||||
- Service latency tracking
|
||||
|
||||
## New NuGet Dependency
|
||||
|
||||
- **MQTTnet** — added to `Directory.Packages.props` and `NATS.E2E.Tests.csproj`
|
||||
|
||||
## Estimated Impact
|
||||
|
||||
- ~45-55 new tests
|
||||
- 5 new fixtures + 7 new test files + 1 existing file extended
|
||||
- Total E2E: ~90-95 tests (from current 42)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-12-e2e-full-gap-coverage-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Add MQTTnet NuGet Package", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Monitoring Endpoint Tests", "status": "pending"},
|
||||
{"id": 3, "subject": "Task 3: Header Pub/Sub Tests", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: Shutdown and Drain Tests", "status": "pending"},
|
||||
{"id": 5, "subject": "Task 5: JetStream Extended Tests", "status": "pending"},
|
||||
{"id": 6, "subject": "Task 6: Cluster Fixture and Tests", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 7: Leaf Node Fixture and Tests", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 8: Gateway Fixture and Tests", "status": "pending"},
|
||||
{"id": 9, "subject": "Task 9: MQTT Fixture and Tests", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "Task 10: WebSocket Fixture and Tests", "status": "pending"},
|
||||
{"id": 11, "subject": "Task 11: Advanced Tests", "status": "pending"},
|
||||
{"id": 12, "subject": "Task 12: Final Verification and Cleanup", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}
|
||||
],
|
||||
"lastUpdated": "2026-03-12T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
# Test Project Split Design
|
||||
|
||||
**Date:** 2026-03-12
|
||||
**Goal:** Split `NATS.Server.Tests` (609 files) into feature-focused test projects for developer ergonomics — easier to run just the tests for the subsystem you're working on.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
NATS.Server.TestUtilities/ # Shared helpers, fixtures, parity tools (class library)
|
||||
NATS.Server.Core.Tests/ # Client, server, parser, config, subscriptions, protocol
|
||||
NATS.Server.Auth.Tests/ # Auth, accounts, permissions, JWT, NKeys
|
||||
NATS.Server.JetStream.Tests/ # JetStream API, streams, consumers, storage, cluster
|
||||
NATS.Server.Raft.Tests/ # RAFT consensus
|
||||
NATS.Server.Clustering.Tests/ # Routes, cluster topology, inter-server protocol
|
||||
NATS.Server.Gateways.Tests/ # Gateway connections, interest modes
|
||||
NATS.Server.LeafNodes.Tests/ # Leaf node connections, hub-spoke
|
||||
NATS.Server.Mqtt.Tests/ # MQTT protocol bridge
|
||||
NATS.Server.Monitoring.Tests/ # Monitor endpoints, events, system events
|
||||
NATS.Server.Transport.Tests/ # WebSocket, TLS, OCSP, IO
|
||||
NATS.E2E.Tests/ # (existing, unchanged)
|
||||
```
|
||||
|
||||
## TestUtilities Contents
|
||||
|
||||
`NATS.Server.TestUtilities` is a **class library** (not a test project).
|
||||
|
||||
### Shared helpers (deduplicated)
|
||||
- `TestPortAllocator` — `GetFreePort()` (currently duplicated in ~51 files)
|
||||
- `SocketTestHelper` — `ReadUntilAsync()`, raw socket connect/read patterns (~25 files)
|
||||
- `ServerTestHelper` — common server startup/teardown patterns
|
||||
|
||||
### Shared fixtures
|
||||
- `JetStreamApiFixture` — moved from root (used by 52 JetStream test files)
|
||||
- `JetStreamClusterFixture` — consolidated from 2 duplicate definitions
|
||||
- `LeafFixture` — consolidated from 3 duplicate definitions
|
||||
|
||||
### Parity utilities (non-test)
|
||||
- `NatsCapabilityInventory.cs`
|
||||
- `ParityRowInspector.cs`
|
||||
- `JetStreamParityTruthMatrix.cs`
|
||||
|
||||
### TestData
|
||||
- `TestData/*.conf` files (copied to output directory)
|
||||
|
||||
## File-to-Project Mapping
|
||||
|
||||
### NATS.Server.Core.Tests (~75 files)
|
||||
|
||||
**Root-level:**
|
||||
ClientClosedReasonTests, ClientFlagsTests, ClientHeaderTests, ClientKindCommandMatrixTests, ClientKindProtocolRoutingTests, ClientKindTests, ClientLifecycleTests, ClientProtocolParityTests, ClientPubSubTests, ClientServerGoParityTests, ClientSlowConsumerTests, ClientTests, ClientTraceModeTests, ClientTraceTests, ClientUnsubTests, ConfigIntegrationTests, ConfigProcessorTests, ConfigReloadTests, ConfigRuntimeParityTests, FlushCoalescingTests, IntegrationTests, InternalClientTests, LoggingTests, MessageTraceTests, MsgTraceGoParityTests, NatsConfLexerTests, NatsConfParserTests, NatsHeaderParserTests, NatsOptionsTests, NoRespondersTests, ParserTests, ResponseRoutingTests, ResponseTrackerTests, RttTests, ServerConfigTests, ServerStatsTests, ServerTests, SignalHandlerTests, SlopwatchSuppressAttribute, SlowConsumerStallGateTests, StallGateTests, SubjectMatchTests, SubjectTransformIntegrationTests, SubjectTransformTests, SubListTests, VerboseModeTests, WriteLoopTests, WriteTimeoutTests, ConcurrencyStressTests
|
||||
|
||||
**Subfolders:** Configuration/ (14), Internal/ (8), IO/ (4), Protocol/ (7), Server/ (7), SubList/ (6), Subscriptions/ (6), Stress/ (3)
|
||||
|
||||
**Parity test files (from Parity/ folder):** NatsStrictCapabilityInventoryTests, JetStreamParityTruthMatrixTests, GoParityRunnerTests, InfrastructureGoParityTests, DifferencesParityClosureTests
|
||||
|
||||
### NATS.Server.Auth.Tests (~50 files)
|
||||
|
||||
**Root-level:**
|
||||
AccountIsolationTests, AccountResolverTests, AccountStatsTests, AccountTests, AuthConfigTests, AuthIntegrationTests, AuthProtocolTests, AuthServiceTests, ClientPermissionsTests, JwtAuthenticatorTests, JwtTests, NKeyAuthenticatorTests, NKeyIntegrationTests, PermissionIntegrationTests, PermissionLruCacheTests, PermissionTemplateTests, SimpleUserPasswordAuthenticatorTests, TokenAuthenticatorTests, UserPasswordAuthenticatorTests, ImportExportTests
|
||||
|
||||
**Subfolders:** Auth/ (25), Accounts/ (5)
|
||||
|
||||
### NATS.Server.JetStream.Tests (~220 files)
|
||||
|
||||
**Root-level:**
|
||||
All `JetStream*` files at root (~55), plus FileStoreTests, FileStoreEncryptionTests, MemStoreTests, StreamStoreContractTests, MirrorSourceRetryTests, ClusterJetStreamConfigProcessorTests
|
||||
|
||||
**Subfolders:** JetStream/ and all sub-folders (163 files)
|
||||
|
||||
### NATS.Server.Raft.Tests (~45 files)
|
||||
|
||||
**Root-level:** RaftConsensusAdvancedParityTests, RaftElectionTests, RaftMembershipParityTests, RaftReplicationTests, RaftSafetyContractTests, RaftSnapshotCatchupTests, RaftSnapshotTransferParityTests, RaftTransportPersistenceTests
|
||||
|
||||
**Subfolders:** Raft/ (36)
|
||||
|
||||
### NATS.Server.Clustering.Tests (~30 files)
|
||||
|
||||
**Root-level:** RouteHandshakeTests, RoutePoolTests, RouteRmsgForwardingTests, RouteSubscriptionPropagationTests, RouteWireSubscriptionProtocolTests, ImplicitDiscoveryTests, InterServerAccountProtocolTests
|
||||
|
||||
**Subfolders:** Routes/ (21), Route/ (1)
|
||||
|
||||
### NATS.Server.Gateways.Tests (~25 files)
|
||||
|
||||
**Root-level:** GatewayAdvancedRemapRuntimeTests, GatewayAdvancedSemanticsTests, GatewayLeafBootstrapTests, GatewayProtocolTests
|
||||
|
||||
**Subfolders:** Gateways/ (21)
|
||||
|
||||
### NATS.Server.LeafNodes.Tests (~30 files)
|
||||
|
||||
**Root-level:** LeafAdvancedSemanticsTests, LeafProtocolTests
|
||||
|
||||
**Subfolders:** LeafNodes/ (26), LeafNode/ (1)
|
||||
|
||||
### NATS.Server.Mqtt.Tests (~30 files)
|
||||
|
||||
**Root-level:** MqttPersistenceTests
|
||||
|
||||
**Subfolders:** Mqtt/ (28)
|
||||
|
||||
### NATS.Server.Monitoring.Tests (~35 files)
|
||||
|
||||
**Root-level:** EventSystemTests, JszMonitorTests, MonitorClusterEndpointTests, MonitorModelTests, MonitorTests, SubszTests, SystemEventsTests, SystemRequestReplyTests
|
||||
|
||||
**Subfolders:** Monitoring/ (21), Events/ (10)
|
||||
|
||||
### NATS.Server.Transport.Tests (~25 files)
|
||||
|
||||
**Root-level:** OcspConfigTests, OcspStaplingTests, TlsConnectionWrapperTests, TlsHelperTests, TlsMapAuthenticatorTests, TlsOcspParityBatch1Tests, TlsOcspParityBatch2Tests, TlsRateLimiterTests, TlsServerTests
|
||||
|
||||
**Subfolders:** WebSocket/ (15), Networking/ (1)
|
||||
|
||||
## Project File Template
|
||||
|
||||
Each test project follows the same base pattern:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
**Project-specific package additions:**
|
||||
|
||||
| Project | Extra packages |
|
||||
|---------|---------------|
|
||||
| Auth.Tests | `NATS.NKeys` |
|
||||
| JetStream.Tests | `NATS.Client.Core`, `JETSTREAM_INTEGRATION_MATRIX` define constant |
|
||||
| Transport.Tests | `Serilog.Sinks.File` (if TLS tests use it) |
|
||||
| Core.Tests | `NATS.Client.Core`, `Serilog.Sinks.File` |
|
||||
| Monitoring.Tests | `NATS.Client.Core` |
|
||||
|
||||
**TestUtilities** is a plain class library:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" /> <!-- for IAsyncLifetime fixtures -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Create TestUtilities
|
||||
- Create `NATS.Server.TestUtilities` project
|
||||
- Extract `GetFreePort()`, `ReadUntilAsync()` into shared helper classes
|
||||
- Move `JetStreamApiFixture`, consolidated `JetStreamClusterFixture`, consolidated `LeafFixture`
|
||||
- Move parity utility files (non-test) and TestData
|
||||
- Update the original `NATS.Server.Tests` to reference TestUtilities
|
||||
- Verify build + all tests pass
|
||||
|
||||
### Phase 2: Split projects one at a time (smallest first)
|
||||
1. Transport.Tests (~25 files)
|
||||
2. Mqtt.Tests (~30 files)
|
||||
3. Gateways.Tests (~25 files)
|
||||
4. LeafNodes.Tests (~30 files)
|
||||
5. Clustering.Tests (~30 files)
|
||||
6. Raft.Tests (~45 files)
|
||||
7. Monitoring.Tests (~35 files)
|
||||
8. Auth.Tests (~50 files)
|
||||
9. JetStream.Tests (~220 files)
|
||||
10. Core.Tests (rename remaining original project)
|
||||
|
||||
Each step:
|
||||
- Create the new `.csproj`
|
||||
- Move files with `git mv` to preserve history
|
||||
- Update namespaces to match new project name
|
||||
- Add to solution file
|
||||
- Remove files from old project
|
||||
- Build + test
|
||||
|
||||
### Phase 3: Cleanup
|
||||
- Delete the original `NATS.Server.Tests` project (now empty)
|
||||
- Verify `dotnet test` from solution root runs all projects
|
||||
- Verify CI still works
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Namespaces updated** to match new project names (e.g., `NATS.Server.Auth.Tests`)
|
||||
- **Root-level files sorted** into matching subsystem projects by prefix/topic
|
||||
- **Storage files** (FileStore, MemStore, StreamStore) → JetStream project
|
||||
- **ImportExportTests** → Auth project
|
||||
- **InternalClientTests** → Core project
|
||||
- **Parity test files** → Core.Tests; parity utility classes → TestUtilities
|
||||
- **Stress test files** → Core.Tests (only 3-4 files, not worth a separate project)
|
||||
- **Trace files** → Core.Tests (tracing is a core feature)
|
||||
@@ -0,0 +1,616 @@
|
||||
# Test Project Split Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Split the monolithic `NATS.Server.Tests` (609 files) into 10 feature-focused test projects + 1 shared test utilities library.
|
||||
|
||||
**Architecture:** Create `NATS.Server.TestUtilities` as a class library with deduplicated helpers and shared fixtures. Then extract test files into subsystem-specific test projects one at a time, smallest first. Each extraction creates a new `.csproj`, moves files with `git mv`, updates namespaces, adds to the solution, and verifies build+test before proceeding to the next.
|
||||
|
||||
**Tech Stack:** .NET 10, xUnit 3, Shouldly, NSubstitute, Central Package Management
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Create NATS.Server.TestUtilities project
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj`
|
||||
- Create: `tests/NATS.Server.TestUtilities/TestPortAllocator.cs`
|
||||
- Create: `tests/NATS.Server.TestUtilities/SocketTestHelper.cs`
|
||||
- Modify: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` (add ProjectReference)
|
||||
- Modify: `NatsDotNet.slnx` (add project)
|
||||
|
||||
**Step 1: Create the TestUtilities csproj**
|
||||
|
||||
```xml
|
||||
<!-- tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="NATS.NKeys" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
**Step 2: Create TestPortAllocator.cs**
|
||||
|
||||
```csharp
|
||||
// tests/NATS.Server.TestUtilities/TestPortAllocator.cs
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace NATS.Server.TestUtilities;
|
||||
|
||||
public static class TestPortAllocator
|
||||
{
|
||||
public static int GetFreePort()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create SocketTestHelper.cs**
|
||||
|
||||
```csharp
|
||||
// tests/NATS.Server.TestUtilities/SocketTestHelper.cs
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.TestUtilities;
|
||||
|
||||
public static class SocketTestHelper
|
||||
{
|
||||
public static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Add ProjectReference to existing NATS.Server.Tests**
|
||||
|
||||
Add to `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` inside the `<ItemGroup>` with `ProjectReference`:
|
||||
```xml
|
||||
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
|
||||
```
|
||||
|
||||
**Step 5: Add TestUtilities to solution file**
|
||||
|
||||
In `NatsDotNet.slnx`, inside `<Folder Name="/tests/">` add:
|
||||
```xml
|
||||
<Project Path="tests/NATS.Server.TestUtilities/NATS.Server.TestUtilities.csproj" />
|
||||
```
|
||||
|
||||
**Step 6: Build to verify**
|
||||
|
||||
Run: `dotnet build`
|
||||
Expected: SUCCESS — TestUtilities compiles, NATS.Server.Tests still compiles.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/NATS.Server.TestUtilities/ NatsDotNet.slnx tests/NATS.Server.Tests/NATS.Server.Tests.csproj
|
||||
git commit -m "feat: create NATS.Server.TestUtilities with shared helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Move shared fixtures and parity utilities to TestUtilities
|
||||
|
||||
**Files:**
|
||||
- Move: `tests/NATS.Server.Tests/JetStreamApiFixture.cs` → `tests/NATS.Server.TestUtilities/JetStreamApiFixture.cs`
|
||||
- Move: `tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFixture.cs` → `tests/NATS.Server.TestUtilities/JetStreamClusterFixture.cs`
|
||||
- Move: `tests/NATS.Server.Tests/LeafNodes/LeafFixture.cs` → `tests/NATS.Server.TestUtilities/LeafFixture.cs`
|
||||
- Move: `tests/NATS.Server.Tests/Parity/NatsCapabilityInventory.cs` → `tests/NATS.Server.TestUtilities/Parity/NatsCapabilityInventory.cs`
|
||||
- Move: `tests/NATS.Server.Tests/Parity/ParityRowInspector.cs` → `tests/NATS.Server.TestUtilities/Parity/ParityRowInspector.cs`
|
||||
- Move: `tests/NATS.Server.Tests/Parity/JetStreamParityTruthMatrix.cs` → `tests/NATS.Server.TestUtilities/Parity/JetStreamParityTruthMatrix.cs`
|
||||
- Move: `tests/NATS.Server.Tests/TestData/*` → `tests/NATS.Server.TestUtilities/TestData/*`
|
||||
|
||||
**Step 1: Move files with git mv**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
git mv NATS.Server.Tests/JetStreamApiFixture.cs NATS.Server.TestUtilities/
|
||||
git mv NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFixture.cs NATS.Server.TestUtilities/
|
||||
git mv NATS.Server.Tests/LeafNodes/LeafFixture.cs NATS.Server.TestUtilities/
|
||||
mkdir -p NATS.Server.TestUtilities/Parity
|
||||
git mv NATS.Server.Tests/Parity/NatsCapabilityInventory.cs NATS.Server.TestUtilities/Parity/
|
||||
git mv NATS.Server.Tests/Parity/ParityRowInspector.cs NATS.Server.TestUtilities/Parity/
|
||||
git mv NATS.Server.Tests/Parity/JetStreamParityTruthMatrix.cs NATS.Server.TestUtilities/Parity/
|
||||
mkdir -p NATS.Server.TestUtilities/TestData
|
||||
git mv NATS.Server.Tests/TestData/* NATS.Server.TestUtilities/TestData/
|
||||
```
|
||||
|
||||
**Step 2: Update namespaces in moved files**
|
||||
|
||||
Change `namespace NATS.Server.Tests;` → `namespace NATS.Server.TestUtilities;` in each moved file.
|
||||
For parity files: `namespace NATS.Server.TestUtilities.Parity;`
|
||||
For fixtures in subfolders that had sub-namespaces (e.g. `NATS.Server.Tests.JetStream.Cluster`), update to `NATS.Server.TestUtilities;`.
|
||||
|
||||
**Step 3: Make fixture classes public**
|
||||
|
||||
The moved fixtures (`JetStreamApiFixture`, `JetStreamClusterFixture`, `LeafFixture`) are likely `internal`. Change them to `public` so test projects can access them.
|
||||
|
||||
**Step 4: Add `using NATS.Server.TestUtilities;` to files that reference moved fixtures**
|
||||
|
||||
All files that reference `JetStreamApiFixture`, `JetStreamClusterFixture`, `LeafFixture`, or parity utilities need the new using directive. This is ~52 files for JetStreamApiFixture, ~20 for cluster fixture, ~5 for LeafFixture.
|
||||
|
||||
**Step 5: Remove TestData entry from NATS.Server.Tests.csproj**
|
||||
|
||||
Remove the `<None Update="TestData\**\*" ...>` item since TestData moved to TestUtilities.
|
||||
|
||||
**Step 6: Build and run tests**
|
||||
|
||||
Run: `dotnet build && dotnet test tests/NATS.Server.Tests --no-build`
|
||||
Expected: All tests pass — fixtures resolved from TestUtilities.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: move shared fixtures and parity utilities to TestUtilities"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract NATS.Server.Transport.Tests (~25 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj`
|
||||
- Move: Root files: OcspConfigTests.cs, OcspStaplingTests.cs, TlsConnectionWrapperTests.cs, TlsHelperTests.cs, TlsMapAuthenticatorTests.cs, TlsOcspParityBatch1Tests.cs, TlsOcspParityBatch2Tests.cs, TlsRateLimiterTests.cs, TlsServerTests.cs
|
||||
- Move: `WebSocket/` folder (15 files)
|
||||
- Move: `Networking/` folder (1 file)
|
||||
- Modify: `NatsDotNet.slnx`
|
||||
|
||||
**Step 1: Create the csproj**
|
||||
|
||||
```xml
|
||||
<!-- tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
**Step 2: Move files with git mv**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
mkdir -p NATS.Server.Transport.Tests
|
||||
git mv NATS.Server.Tests/OcspConfigTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/OcspStaplingTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsConnectionWrapperTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsHelperTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsMapAuthenticatorTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsOcspParityBatch1Tests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsOcspParityBatch2Tests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsRateLimiterTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/TlsServerTests.cs NATS.Server.Transport.Tests/
|
||||
git mv NATS.Server.Tests/WebSocket NATS.Server.Transport.Tests/WebSocket
|
||||
git mv NATS.Server.Tests/Networking NATS.Server.Transport.Tests/Networking
|
||||
```
|
||||
|
||||
**Step 3: Update namespaces**
|
||||
|
||||
In all moved files, change:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Transport.Tests;`
|
||||
- `namespace NATS.Server.Tests.WebSocket;` → `namespace NATS.Server.Transport.Tests.WebSocket;`
|
||||
- `namespace NATS.Server.Tests.Networking;` → `namespace NATS.Server.Transport.Tests.Networking;`
|
||||
|
||||
**Step 4: Replace private GetFreePort/ReadUntilAsync with TestUtilities calls**
|
||||
|
||||
In each moved file that has `private static int GetFreePort()` or `private static async Task<string> ReadUntilAsync(...)`:
|
||||
- Delete the private method
|
||||
- Add `using NATS.Server.TestUtilities;`
|
||||
- Replace `GetFreePort()` → `TestPortAllocator.GetFreePort()`
|
||||
- Replace `ReadUntilAsync(` → `SocketTestHelper.ReadUntilAsync(`
|
||||
|
||||
**Step 5: Add to solution file**
|
||||
|
||||
In `NatsDotNet.slnx`, inside `/tests/`:
|
||||
```xml
|
||||
<Project Path="tests/NATS.Server.Transport.Tests/NATS.Server.Transport.Tests.csproj" />
|
||||
```
|
||||
|
||||
**Step 6: Build and test**
|
||||
|
||||
Run: `dotnet build && dotnet test tests/NATS.Server.Transport.Tests --no-build`
|
||||
Expected: All Transport tests pass.
|
||||
|
||||
Run: `dotnet test tests/NATS.Server.Tests --no-build`
|
||||
Expected: Remaining tests still pass.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: extract NATS.Server.Transport.Tests project"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extract NATS.Server.Mqtt.Tests (~30 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Mqtt.Tests/NATS.Server.Mqtt.Tests.csproj`
|
||||
- Move: Root file: MqttPersistenceTests.cs
|
||||
- Move: `Mqtt/` folder (28 files)
|
||||
|
||||
**Step 1: Create csproj** (same template as Transport, no Serilog needed)
|
||||
|
||||
**Step 2: Move files**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
mkdir -p NATS.Server.Mqtt.Tests
|
||||
git mv NATS.Server.Tests/MqttPersistenceTests.cs NATS.Server.Mqtt.Tests/
|
||||
git mv NATS.Server.Tests/Mqtt NATS.Server.Mqtt.Tests/Mqtt
|
||||
```
|
||||
|
||||
**Step 3: Update namespaces**
|
||||
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Mqtt.Tests;`
|
||||
- `namespace NATS.Server.Tests.Mqtt;` → `namespace NATS.Server.Mqtt.Tests.Mqtt;`
|
||||
|
||||
**Step 4: Replace duplicated helpers with TestUtilities calls** (same pattern as Task 2)
|
||||
|
||||
**Step 5: Add to solution file**
|
||||
|
||||
**Step 6: Build and test**
|
||||
|
||||
Run: `dotnet build && dotnet test tests/NATS.Server.Mqtt.Tests --no-build`
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: extract NATS.Server.Mqtt.Tests project"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract NATS.Server.Gateways.Tests (~25 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Gateways.Tests/NATS.Server.Gateways.Tests.csproj`
|
||||
- Move: Root files: GatewayAdvancedRemapRuntimeTests.cs, GatewayAdvancedSemanticsTests.cs, GatewayLeafBootstrapTests.cs, GatewayProtocolTests.cs
|
||||
- Move: `Gateways/` folder (21 files)
|
||||
|
||||
**Steps:** Same pattern as Tasks 2-3.
|
||||
|
||||
Namespace changes:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Gateways.Tests;`
|
||||
- `namespace NATS.Server.Tests.Gateways;` → `namespace NATS.Server.Gateways.Tests.Gateways;`
|
||||
|
||||
May need `NATS.Client.Core` package if any gateway test uses `NatsConnection`.
|
||||
|
||||
**Commit:** `git commit -m "refactor: extract NATS.Server.Gateways.Tests project"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extract NATS.Server.LeafNodes.Tests (~30 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.LeafNodes.Tests/NATS.Server.LeafNodes.Tests.csproj`
|
||||
- Move: Root files: LeafAdvancedSemanticsTests.cs, LeafProtocolTests.cs
|
||||
- Move: `LeafNodes/` folder (26 files) — note: `LeafFixture.cs` already moved to TestUtilities
|
||||
- Move: `LeafNode/` folder (1 file)
|
||||
|
||||
**Steps:** Same pattern. The `LeafFixture` reference now comes from TestUtilities — add `using NATS.Server.TestUtilities;`.
|
||||
|
||||
Namespace changes:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.LeafNodes.Tests;`
|
||||
- `namespace NATS.Server.Tests.LeafNodes;` → `namespace NATS.Server.LeafNodes.Tests.LeafNodes;`
|
||||
- `namespace NATS.Server.Tests.LeafNode;` → `namespace NATS.Server.LeafNodes.Tests.LeafNode;`
|
||||
|
||||
**Commit:** `git commit -m "refactor: extract NATS.Server.LeafNodes.Tests project"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Extract NATS.Server.Clustering.Tests (~30 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Clustering.Tests/NATS.Server.Clustering.Tests.csproj`
|
||||
- Move: Root files: RouteHandshakeTests.cs, RoutePoolTests.cs, RouteRmsgForwardingTests.cs, RouteSubscriptionPropagationTests.cs, RouteWireSubscriptionProtocolTests.cs, ImplicitDiscoveryTests.cs, InterServerAccountProtocolTests.cs
|
||||
- Move: `Routes/` folder (21 files)
|
||||
- Move: `Route/` folder (1 file)
|
||||
|
||||
**Steps:** Same pattern.
|
||||
|
||||
Namespace changes:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Clustering.Tests;`
|
||||
- `namespace NATS.Server.Tests.Routes;` → `namespace NATS.Server.Clustering.Tests.Routes;`
|
||||
- `namespace NATS.Server.Tests.Route;` → `namespace NATS.Server.Clustering.Tests.Route;`
|
||||
|
||||
**Commit:** `git commit -m "refactor: extract NATS.Server.Clustering.Tests project"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Extract NATS.Server.Raft.Tests (~45 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Raft.Tests/NATS.Server.Raft.Tests.csproj`
|
||||
- Move: Root files: RaftConsensusAdvancedParityTests.cs, RaftElectionTests.cs, RaftMembershipParityTests.cs, RaftReplicationTests.cs, RaftSafetyContractTests.cs, RaftSnapshotCatchupTests.cs, RaftSnapshotTransferParityTests.cs, RaftTransportPersistenceTests.cs
|
||||
- Move: `Raft/` folder (36 files)
|
||||
|
||||
**Steps:** Same pattern.
|
||||
|
||||
Namespace changes:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Raft.Tests;`
|
||||
- `namespace NATS.Server.Tests.Raft;` → `namespace NATS.Server.Raft.Tests.Raft;`
|
||||
|
||||
**Commit:** `git commit -m "refactor: extract NATS.Server.Raft.Tests project"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Extract NATS.Server.Monitoring.Tests (~35 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Monitoring.Tests/NATS.Server.Monitoring.Tests.csproj`
|
||||
- Move: Root files: EventSystemTests.cs, JszMonitorTests.cs, MonitorClusterEndpointTests.cs, MonitorModelTests.cs, MonitorTests.cs, SubszTests.cs, SystemEventsTests.cs, SystemRequestReplyTests.cs
|
||||
- Move: `Monitoring/` folder (21 files)
|
||||
- Move: `Events/` folder (10 files)
|
||||
|
||||
**Steps:** Same pattern. Needs `NATS.Client.Core` package for integration tests.
|
||||
|
||||
Namespace changes:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Monitoring.Tests;`
|
||||
- `namespace NATS.Server.Tests.Monitoring;` → `namespace NATS.Server.Monitoring.Tests.Monitoring;`
|
||||
- `namespace NATS.Server.Tests.Events;` → `namespace NATS.Server.Monitoring.Tests.Events;`
|
||||
|
||||
**Commit:** `git commit -m "refactor: extract NATS.Server.Monitoring.Tests project"`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Extract NATS.Server.Auth.Tests (~50 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.Auth.Tests/NATS.Server.Auth.Tests.csproj`
|
||||
- Move: Root files: AccountIsolationTests.cs, AccountResolverTests.cs, AccountStatsTests.cs, AccountTests.cs, AuthConfigTests.cs, AuthIntegrationTests.cs, AuthProtocolTests.cs, AuthServiceTests.cs, ClientPermissionsTests.cs, JwtAuthenticatorTests.cs, JwtTests.cs, NKeyAuthenticatorTests.cs, NKeyIntegrationTests.cs, PermissionIntegrationTests.cs, PermissionLruCacheTests.cs, PermissionTemplateTests.cs, SimpleUserPasswordAuthenticatorTests.cs, TokenAuthenticatorTests.cs, UserPasswordAuthenticatorTests.cs, ImportExportTests.cs
|
||||
- Move: `Auth/` folder (25 files)
|
||||
- Move: `Accounts/` folder (5 files)
|
||||
|
||||
**Steps:** Same pattern. Needs `NATS.NKeys` and `NATS.Client.Core` packages.
|
||||
|
||||
Namespace changes:
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.Auth.Tests;`
|
||||
- `namespace NATS.Server.Tests.Auth;` → `namespace NATS.Server.Auth.Tests.Auth;`
|
||||
- `namespace NATS.Server.Tests.Accounts;` → `namespace NATS.Server.Auth.Tests.Accounts;`
|
||||
|
||||
**Commit:** `git commit -m "refactor: extract NATS.Server.Auth.Tests project"`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Extract NATS.Server.JetStream.Tests (~220 files)
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj`
|
||||
- Move: All root `JetStream*` files (~55 files)
|
||||
- Move: Root storage files: FileStoreTests.cs, FileStoreEncryptionTests.cs, MemStoreTests.cs, StreamStoreContractTests.cs, MirrorSourceRetryTests.cs, ClusterJetStreamConfigProcessorTests.cs
|
||||
- Move: `JetStream/` folder and all sub-folders (163 files) — note: `JetStreamClusterFixture.cs` already in TestUtilities
|
||||
|
||||
**Step 1: Create csproj with JetStream-specific additions**
|
||||
|
||||
```xml
|
||||
<!-- tests/NATS.Server.JetStream.Tests/NATS.Server.JetStream.Tests.csproj -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<DefineConstants>$(DefineConstants);JETSTREAM_INTEGRATION_MATRIX</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
**Step 2: Move files** — this is the largest move. Use a script:
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
mkdir -p NATS.Server.JetStream.Tests
|
||||
|
||||
# Move JetStream subfolder (preserves internal structure)
|
||||
git mv NATS.Server.Tests/JetStream NATS.Server.JetStream.Tests/JetStream
|
||||
|
||||
# Move root JetStream* files
|
||||
for f in NATS.Server.Tests/JetStream*.cs; do
|
||||
git mv "$f" NATS.Server.JetStream.Tests/
|
||||
done
|
||||
|
||||
# Move storage-related root files
|
||||
git mv NATS.Server.Tests/FileStoreTests.cs NATS.Server.JetStream.Tests/
|
||||
git mv NATS.Server.Tests/FileStoreEncryptionTests.cs NATS.Server.JetStream.Tests/
|
||||
git mv NATS.Server.Tests/MemStoreTests.cs NATS.Server.JetStream.Tests/
|
||||
git mv NATS.Server.Tests/StreamStoreContractTests.cs NATS.Server.JetStream.Tests/
|
||||
git mv NATS.Server.Tests/MirrorSourceRetryTests.cs NATS.Server.JetStream.Tests/
|
||||
git mv NATS.Server.Tests/ClusterJetStreamConfigProcessorTests.cs NATS.Server.JetStream.Tests/
|
||||
```
|
||||
|
||||
**Step 3: Update namespaces**
|
||||
|
||||
- `namespace NATS.Server.Tests;` → `namespace NATS.Server.JetStream.Tests;` (root files)
|
||||
- `namespace NATS.Server.Tests.JetStream;` → `namespace NATS.Server.JetStream.Tests.JetStream;`
|
||||
- All JetStream sub-namespaces follow the pattern (e.g., `NATS.Server.Tests.JetStream.Cluster` → `NATS.Server.JetStream.Tests.JetStream.Cluster`)
|
||||
|
||||
**Step 4: Update fixture references**
|
||||
|
||||
Files using `JetStreamApiFixture` or `JetStreamClusterFixture` need `using NATS.Server.TestUtilities;` since the fixtures moved there in Task 1.
|
||||
|
||||
**Step 5: Replace duplicated helpers with TestUtilities calls**
|
||||
|
||||
**Step 6: Add to solution, build, test**
|
||||
|
||||
Run: `dotnet build && dotnet test tests/NATS.Server.JetStream.Tests --no-build`
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: extract NATS.Server.JetStream.Tests project"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Rename remaining project to NATS.Server.Core.Tests
|
||||
|
||||
**Files:**
|
||||
- Rename: `tests/NATS.Server.Tests/` → `tests/NATS.Server.Core.Tests/`
|
||||
- Rename: `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` → `tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj`
|
||||
- Modify: `NatsDotNet.slnx` (update path)
|
||||
|
||||
**Step 1: Rename directory and csproj**
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
git mv NATS.Server.Tests NATS.Server.Core.Tests
|
||||
cd NATS.Server.Core.Tests
|
||||
git mv NATS.Server.Tests.csproj NATS.Server.Core.Tests.csproj
|
||||
```
|
||||
|
||||
**Step 2: Update solution file**
|
||||
|
||||
Replace `tests/NATS.Server.Tests/NATS.Server.Tests.csproj` with `tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj`.
|
||||
|
||||
**Step 3: Clean up csproj**
|
||||
|
||||
Remove the `JETSTREAM_INTEGRATION_MATRIX` DefineConstants (that moved to JetStream.Tests). Remove any package references only needed by extracted projects (e.g., `NATS.NKeys` if only auth tests needed it). Keep `NATS.Client.Core` and `Serilog.Sinks.File`.
|
||||
|
||||
**Step 4: Update namespaces**
|
||||
|
||||
Change `namespace NATS.Server.Tests;` → `namespace NATS.Server.Core.Tests;` in all remaining files.
|
||||
Update sub-namespaces: `NATS.Server.Tests.Configuration` → `NATS.Server.Core.Tests.Configuration`, etc.
|
||||
|
||||
**Step 5: Replace duplicated helpers with TestUtilities calls**
|
||||
|
||||
**Step 6: Build and test all projects**
|
||||
|
||||
Run: `dotnet build && dotnet test`
|
||||
Expected: All projects build and all tests pass.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: rename remaining tests to NATS.Server.Core.Tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Final verification and cleanup
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
```bash
|
||||
dotnet test -v normal 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: All test projects discovered and run. Total test count should match original (~6,409 parameterized tests).
|
||||
|
||||
**Step 2: Verify each project runs independently**
|
||||
|
||||
```bash
|
||||
dotnet test tests/NATS.Server.Core.Tests
|
||||
dotnet test tests/NATS.Server.Auth.Tests
|
||||
dotnet test tests/NATS.Server.JetStream.Tests
|
||||
dotnet test tests/NATS.Server.Raft.Tests
|
||||
dotnet test tests/NATS.Server.Clustering.Tests
|
||||
dotnet test tests/NATS.Server.Gateways.Tests
|
||||
dotnet test tests/NATS.Server.LeafNodes.Tests
|
||||
dotnet test tests/NATS.Server.Mqtt.Tests
|
||||
dotnet test tests/NATS.Server.Monitoring.Tests
|
||||
dotnet test tests/NATS.Server.Transport.Tests
|
||||
dotnet test tests/NATS.E2E.Tests
|
||||
```
|
||||
|
||||
**Step 3: Verify solution structure**
|
||||
|
||||
```bash
|
||||
dotnet sln NatsDotNet.slnx list
|
||||
```
|
||||
|
||||
Expected: 13 projects listed (2 src + 11 test).
|
||||
|
||||
**Step 4: Check for orphaned files**
|
||||
|
||||
```bash
|
||||
find tests/NATS.Server.Core.Tests -name "*.cs" -not -path "*/obj/*" -not -path "*/bin/*" | wc -l
|
||||
```
|
||||
|
||||
Should be ~75 files. Any file that doesn't belong in Core should be moved to its correct project.
|
||||
|
||||
**Step 5: Clean build artifacts and rebuild from scratch**
|
||||
|
||||
```bash
|
||||
dotnet clean && dotnet build && dotnet test
|
||||
```
|
||||
|
||||
**Step 6: Commit any cleanup**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final cleanup after test project split"
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-12-test-project-split-plan.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Create NATS.Server.TestUtilities project", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Move shared fixtures and parity utilities to TestUtilities", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: Extract NATS.Server.Transport.Tests (~25 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Extract NATS.Server.Mqtt.Tests (~30 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Extract NATS.Server.Gateways.Tests (~25 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: Extract NATS.Server.LeafNodes.Tests (~30 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: Extract NATS.Server.Clustering.Tests (~30 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "Task 7: Extract NATS.Server.Raft.Tests (~45 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Extract NATS.Server.Monitoring.Tests (~35 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 9, "subject": "Task 9: Extract NATS.Server.Auth.Tests (~50 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 10, "subject": "Task 10: Extract NATS.Server.JetStream.Tests (~220 files)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 11, "subject": "Task 11: Rename remaining to NATS.Server.Core.Tests", "status": "pending", "blockedBy": [2, 3, 4, 5, 6, 7, 8, 9, 10]},
|
||||
{"id": 12, "subject": "Task 12: Final verification and cleanup", "status": "pending", "blockedBy": [11]}
|
||||
],
|
||||
"lastUpdated": "2026-03-12T00:00:00Z"
|
||||
}
|
||||
@@ -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,7 +454,14 @@ public sealed class Account : IDisposable
|
||||
return new ServiceExportInfo(subject, se.ResponseType, approved, isWildcard);
|
||||
}
|
||||
|
||||
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved)
|
||||
/// <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
|
||||
{
|
||||
@@ -318,9 +472,15 @@ public sealed class Account : IDisposable
|
||||
Auth = auth,
|
||||
Account = this,
|
||||
ResponseType = responseType,
|
||||
Latency = latency,
|
||||
};
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -334,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)
|
||||
@@ -363,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))
|
||||
@@ -388,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)
|
||||
{
|
||||
@@ -403,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);
|
||||
@@ -447,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);
|
||||
|
||||
@@ -459,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;
|
||||
|
||||
@@ -466,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;
|
||||
|
||||
@@ -474,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))
|
||||
@@ -485,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))
|
||||
@@ -551,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);
|
||||
|
||||
@@ -561,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>
|
||||
@@ -588,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;
|
||||
|
||||
@@ -596,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))
|
||||
@@ -611,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;
|
||||
|
||||
@@ -682,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);
|
||||
@@ -750,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);
|
||||
@@ -759,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;
|
||||
|
||||
@@ -766,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 _);
|
||||
|
||||
@@ -784,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);
|
||||
@@ -794,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);
|
||||
@@ -817,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))
|
||||
@@ -846,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)
|
||||
@@ -923,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);
|
||||
@@ -939,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();
|
||||
}
|
||||
|
||||
@@ -1005,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,7 +2,53 @@ 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>
|
||||
public List<ExportDefinition>? Exports { get; init; }
|
||||
|
||||
/// <summary>Service and stream imports into this account.</summary>
|
||||
public List<ImportDefinition>? Imports { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an export declaration in config: exports = [{ service: "sub" }] or [{ stream: "sub" }].
|
||||
/// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap.
|
||||
/// </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>
|
||||
public string? LatencySubject { get; init; }
|
||||
|
||||
/// <summary>Latency sampling percentage (1–100, default 100).</summary>
|
||||
public int LatencySampling { get; init; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an import declaration in config:
|
||||
/// imports = [{ service: { account: X, subject: "sub" }, to: "local" }].
|
||||
/// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap.
|
||||
/// </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>();
|
||||
@@ -291,7 +295,55 @@ public static class ConfigProcessor
|
||||
ParseAccounts(accountsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (resolver, operator, etc.)
|
||||
// Server-level subject mappings: mappings { src: dest }
|
||||
// Go reference: server/opts.go — "mappings" case
|
||||
case "mappings" or "maps":
|
||||
if (value is Dictionary<string, object?> mappingsDict)
|
||||
{
|
||||
opts.SubjectMappings ??= new Dictionary<string, string>();
|
||||
foreach (var (src, dest) in mappingsDict)
|
||||
{
|
||||
if (dest is string destStr)
|
||||
opts.SubjectMappings[src] = destStr;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// JWT operator mode — trusted operator public NKeys
|
||||
// Go reference: server/opts.go — "trusted_keys" / "trusted" case
|
||||
case "trusted_keys" or "trusted":
|
||||
opts.TrustedKeys = ParseStringArray(value);
|
||||
break;
|
||||
|
||||
// JWT resolver type and preload
|
||||
// Go reference: server/opts.go — "resolver" case
|
||||
case "resolver" or "account_resolver" or "accounts_resolver":
|
||||
if (value is string resolverStr && resolverStr.Equals("MEMORY", StringComparison.OrdinalIgnoreCase))
|
||||
opts.AccountResolver = new Auth.Jwt.MemAccountResolver();
|
||||
break;
|
||||
|
||||
// Pre-load account JWTs into the resolver
|
||||
// Go reference: server/opts.go — "resolver_preload" case
|
||||
case "resolver_preload":
|
||||
if (value is Dictionary<string, object?> preloadDict && opts.AccountResolver != null)
|
||||
{
|
||||
foreach (var (accNkey, jwtObj) in preloadDict)
|
||||
{
|
||||
if (jwtObj is string jwt)
|
||||
opts.AccountResolver.StoreAsync(accNkey, jwt).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// Operator key (can derive trusted_keys from operator JWT — for now just accept NKeys directly)
|
||||
case "operator" or "operators" or "root" or "roots" or "root_operators" or "root_operator":
|
||||
// For simple mode: treat as trusted_keys alias if string array
|
||||
opts.TrustedKeys ??= ParseStringArray(value);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored
|
||||
default:
|
||||
warnings.Add(new UnknownConfigFieldWarning(key).Message);
|
||||
break;
|
||||
@@ -375,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
|
||||
@@ -975,6 +1028,8 @@ public static class ConfigProcessor
|
||||
int maxConnections = 0;
|
||||
int maxSubscriptions = 0;
|
||||
List<object?>? userList = null;
|
||||
List<ExportDefinition>? exports = null;
|
||||
List<ImportDefinition>? imports = null;
|
||||
|
||||
foreach (var (key, value) in acctDict)
|
||||
{
|
||||
@@ -989,6 +1044,21 @@ public static class ConfigProcessor
|
||||
break;
|
||||
case "max_subscriptions" or "max_subs":
|
||||
maxSubscriptions = ToInt(value);
|
||||
break;
|
||||
case "exports":
|
||||
if (value is List<object?> exportList)
|
||||
exports = ParseExports(exportList);
|
||||
break;
|
||||
case "imports":
|
||||
if (value is List<object?> importList)
|
||||
imports = ParseImports(importList);
|
||||
break;
|
||||
case "mappings" or "maps":
|
||||
if (value is Dictionary<string, object?> mappingsDict)
|
||||
{
|
||||
// Account-level subject mappings not yet supported
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -997,6 +1067,8 @@ public static class ConfigProcessor
|
||||
{
|
||||
MaxConnections = maxConnections,
|
||||
MaxSubscriptions = maxSubscriptions,
|
||||
Exports = exports,
|
||||
Imports = imports,
|
||||
};
|
||||
|
||||
if (userList is not null)
|
||||
@@ -1020,6 +1092,140 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an exports array: [{ service: "sub" }, { stream: "sub" }].
|
||||
/// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap.
|
||||
/// </summary>
|
||||
private static List<ExportDefinition> ParseExports(List<object?> exportList)
|
||||
{
|
||||
var result = new List<ExportDefinition>();
|
||||
foreach (var item in exportList)
|
||||
{
|
||||
if (item is not Dictionary<string, object?> dict)
|
||||
continue;
|
||||
|
||||
string? service = null, stream = null;
|
||||
string? latencySubject = null;
|
||||
int latencySampling = 100;
|
||||
|
||||
foreach (var (k, v) in dict)
|
||||
{
|
||||
switch (k.ToLowerInvariant())
|
||||
{
|
||||
case "service":
|
||||
service = ToString(v);
|
||||
break;
|
||||
case "stream":
|
||||
stream = ToString(v);
|
||||
break;
|
||||
case "latency":
|
||||
// latency can be a string (subject only) or a map { subject, sampling }
|
||||
// Go reference: server/opts.go — parseServiceLatency
|
||||
if (v is string latStr)
|
||||
{
|
||||
latencySubject = latStr;
|
||||
}
|
||||
else if (v is Dictionary<string, object?> latDict)
|
||||
{
|
||||
foreach (var (lk, lv) in latDict)
|
||||
{
|
||||
switch (lk.ToLowerInvariant())
|
||||
{
|
||||
case "subject":
|
||||
latencySubject = ToString(lv);
|
||||
break;
|
||||
case "sampling":
|
||||
latencySampling = ToInt(lv);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new ExportDefinition
|
||||
{
|
||||
Service = service,
|
||||
Stream = stream,
|
||||
LatencySubject = latencySubject,
|
||||
LatencySampling = latencySampling,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an imports array: [{ service: { account: X, subject: "sub" }, to: "local" }].
|
||||
/// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap.
|
||||
/// </summary>
|
||||
private static List<ImportDefinition> ParseImports(List<object?> importList)
|
||||
{
|
||||
var result = new List<ImportDefinition>();
|
||||
foreach (var item in importList)
|
||||
{
|
||||
if (item is not Dictionary<string, object?> dict)
|
||||
continue;
|
||||
|
||||
string? serviceAccount = null, serviceSubject = null;
|
||||
string? streamAccount = null, streamSubject = null;
|
||||
string? to = null;
|
||||
|
||||
foreach (var (k, v) in dict)
|
||||
{
|
||||
switch (k.ToLowerInvariant())
|
||||
{
|
||||
case "service" when v is Dictionary<string, object?> svcDict:
|
||||
foreach (var (sk, sv) in svcDict)
|
||||
{
|
||||
switch (sk.ToLowerInvariant())
|
||||
{
|
||||
case "account":
|
||||
serviceAccount = ToString(sv);
|
||||
break;
|
||||
case "subject":
|
||||
serviceSubject = ToString(sv);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "stream" when v is Dictionary<string, object?> strmDict:
|
||||
foreach (var (sk, sv) in strmDict)
|
||||
{
|
||||
switch (sk.ToLowerInvariant())
|
||||
{
|
||||
case "account":
|
||||
streamAccount = ToString(sv);
|
||||
break;
|
||||
case "subject":
|
||||
streamSubject = ToString(sv);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "to":
|
||||
to = ToString(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new ImportDefinition
|
||||
{
|
||||
ServiceAccount = serviceAccount,
|
||||
ServiceSubject = serviceSubject,
|
||||
StreamAccount = streamAccount,
|
||||
StreamSubject = streamSubject,
|
||||
To = to,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a users array into plain users and NKey users.
|
||||
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
|
||||
@@ -1549,12 +1755,13 @@ public static class ConfigProcessor
|
||||
|
||||
// ─── Type conversion helpers ───────────────────────────────────
|
||||
|
||||
// Go: opts.go — strconv.Atoi after strings.TrimSuffix(s, "%") for sampling values.
|
||||
private static int ToInt(object? value) => value switch
|
||||
{
|
||||
long l => (int)l,
|
||||
int i => i,
|
||||
double d => (int)d,
|
||||
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i,
|
||||
string s when int.TryParse(s.AsSpan().TrimEnd('%'), NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"),
|
||||
};
|
||||
|
||||
@@ -1623,6 +1830,30 @@ public static class ConfigProcessor
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to double"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses a config value that can be a single string or a list of strings into a string[].
|
||||
/// Go reference: server/opts.go — parseTrustedKeys accepts string, []string, []interface{}.
|
||||
/// </summary>
|
||||
private static string[]? ParseStringArray(object? value)
|
||||
{
|
||||
if (value is List<object?> list)
|
||||
{
|
||||
var result = new List<string>(list.Count);
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is string s)
|
||||
result.Add(s);
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result.ToArray() : null;
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
return [str];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ToStringList(object? value)
|
||||
{
|
||||
if (value is List<object?> list)
|
||||
@@ -1651,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 ?? [];
|
||||
}
|
||||
|
||||
@@ -1661,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;
|
||||
}
|
||||
|
||||
@@ -1671,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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user