feat: add structured logging, Shouldly assertions, CPM, and project documentation
- Add Microsoft.Extensions.Logging + Serilog to NatsServer and NatsClient - Convert all test assertions from xUnit Assert to Shouldly - Add NSubstitute package for future mocking needs - Introduce Central Package Management via Directory.Packages.props - Add documentation_rules.md with style guide, generation/update rules, component map - Generate 10 documentation files across 5 component folders (GettingStarted, Protocol, Subscriptions, Server, Configuration/Operations) - Update CLAUDE.md with logging, testing, porting, agent model, CPM, and documentation guidance
This commit is contained in:
50
CLAUDE.md
50
CLAUDE.md
@@ -145,6 +145,56 @@ Client PUB → parser → permission check → Sublist.Match() →
|
||||
└─ 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
|
||||
|
||||
23
Directory.Packages.props
Normal file
23
Directory.Packages.props
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Logging -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
|
||||
<!-- Testing -->
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
<!-- NATS Client (integration tests) -->
|
||||
<PackageVersion Include="NATS.Client.Core" Version="2.7.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
185
Documentation/Configuration/Overview.md
Normal file
185
Documentation/Configuration/Overview.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Configuration Overview
|
||||
|
||||
`NatsOptions` is the single configuration object passed to `NatsServer` at construction time. The host application (`Program.cs`) also reads CLI arguments to populate it before server startup.
|
||||
|
||||
Go reference: `golang/nats-server/server/opts.go`
|
||||
|
||||
---
|
||||
|
||||
## NatsOptions Class
|
||||
|
||||
`NatsOptions` is a plain mutable class with no validation logic — values are consumed directly by `NatsServer` and `NatsClient`.
|
||||
|
||||
```csharp
|
||||
namespace NATS.Server;
|
||||
|
||||
public sealed class NatsOptions
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
public int Port { get; set; } = 4222;
|
||||
public string? ServerName { get; set; }
|
||||
public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
|
||||
public int MaxControlLine { get; set; } = 4096;
|
||||
public int MaxConnections { get; set; } = 65536;
|
||||
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
|
||||
public int MaxPingsOut { get; set; } = 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Option reference
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `Host` | `string` | `"0.0.0.0"` | Bind address for the TCP listener. Use `"127.0.0.1"` to restrict to loopback. |
|
||||
| `Port` | `int` | `4222` | Client listen port. Standard NATS client port. |
|
||||
| `ServerName` | `string?` | `null` (auto: `nats-dotnet-{MachineName}`) | Server name sent in the `INFO` message. When `null`, the server constructs a name from `Environment.MachineName`. |
|
||||
| `MaxPayload` | `int` | `1048576` (1 MB) | Maximum allowed message payload in bytes. Clients that publish larger payloads are rejected. |
|
||||
| `MaxControlLine` | `int` | `4096` | Maximum protocol control line size in bytes. Matches the Go server default. |
|
||||
| `MaxConnections` | `int` | `65536` | Maximum concurrent client connections the server will accept. |
|
||||
| `PingInterval` | `TimeSpan` | `2 minutes` | Interval between server-initiated `PING` messages to connected clients. |
|
||||
| `MaxPingsOut` | `int` | `2` | Number of outstanding `PING`s without a `PONG` response before the server disconnects a client. |
|
||||
|
||||
### How ServerName is resolved
|
||||
|
||||
`NatsServer` constructs the `ServerInfo` sent to each client at connection time. If `ServerName` is `null`, it uses `nats-dotnet-{Environment.MachineName}`:
|
||||
|
||||
```csharp
|
||||
_serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
|
||||
ServerName = options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = options.Host,
|
||||
Port = options.Port,
|
||||
MaxPayload = options.MaxPayload,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Arguments
|
||||
|
||||
`Program.cs` parses command-line arguments before creating `NatsServer`. The three supported flags map directly to `NatsOptions` fields:
|
||||
|
||||
| Flag | Alias | Field | Example |
|
||||
|------|-------|-------|---------|
|
||||
| `-p` | `--port` | `Port` | `-p 14222` |
|
||||
| `-a` | `--addr` | `Host` | `-a 127.0.0.1` |
|
||||
| `-n` | `--name` | `ServerName` | `-n my-server` |
|
||||
|
||||
```csharp
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "-p" or "--port" when i + 1 < args.Length:
|
||||
options.Port = int.Parse(args[++i]);
|
||||
break;
|
||||
case "-a" or "--addr" when i + 1 < args.Length:
|
||||
options.Host = args[++i];
|
||||
break;
|
||||
case "-n" or "--name" when i + 1 < args.Length:
|
||||
options.ServerName = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Unrecognized flags are silently ignored. There is no `--help` output.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Constants
|
||||
|
||||
`NatsProtocol` defines wire-level constants that mirror the Go server's defaults. These values are used by the parser and the `INFO` response:
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `MaxControlLineSize` | `4096` | Maximum bytes in a protocol control line |
|
||||
| `MaxPayloadSize` | `1048576` | Maximum message payload in bytes (1 MB) |
|
||||
| `DefaultPort` | `4222` | Standard NATS client port |
|
||||
| `Version` | `"0.1.0"` | Server version string sent in `INFO` |
|
||||
| `ProtoVersion` | `1` | NATS protocol version number sent in `INFO` |
|
||||
|
||||
```csharp
|
||||
public static class NatsProtocol
|
||||
{
|
||||
public const int MaxControlLineSize = 4096;
|
||||
public const int MaxPayloadSize = 1024 * 1024; // 1MB
|
||||
public const int DefaultPort = 4222;
|
||||
public const string Version = "0.1.0";
|
||||
public const int ProtoVersion = 1;
|
||||
}
|
||||
```
|
||||
|
||||
`MaxControlLine` in `NatsOptions` and `MaxControlLineSize` in `NatsProtocol` carry the same value. `NatsOptions.MaxPayload` is used as the per-server runtime limit passed into `NatsParser`; `NatsProtocol.MaxPayloadSize` is the compile-time default.
|
||||
|
||||
---
|
||||
|
||||
## Logging Configuration
|
||||
|
||||
### Serilog setup
|
||||
|
||||
Logging uses [Serilog](https://serilog.net/) with the console sink, configured in `Program.cs` before any other code runs:
|
||||
|
||||
```csharp
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
```
|
||||
|
||||
The output template produces lines like:
|
||||
|
||||
```
|
||||
[14:32:01 INF] Listening on 0.0.0.0:4222
|
||||
[14:32:01 DBG] Client 1 connected from 127.0.0.1:54321
|
||||
```
|
||||
|
||||
### ILoggerFactory injection
|
||||
|
||||
`Program.cs` wraps the Serilog logger in a `SerilogLoggerFactory` and passes it to `NatsServer`:
|
||||
|
||||
```csharp
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
var server = new NatsServer(options, loggerFactory);
|
||||
```
|
||||
|
||||
`NatsServer` stores the factory and uses it to create two kinds of loggers:
|
||||
|
||||
- **`ILogger<NatsServer>`** — obtained via `loggerFactory.CreateLogger<NatsServer>()`, used for server-level events (listening, client connect/disconnect).
|
||||
- **`ILogger` with named category** — obtained via `loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]")`, created per connection. This gives each client a distinct category in log output so messages can be correlated by client ID without structured log properties.
|
||||
|
||||
```csharp
|
||||
// In NatsServer.StartAsync — one logger per accepted connection
|
||||
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
|
||||
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
|
||||
```
|
||||
|
||||
`NatsClient` takes `ILogger` (not `ILogger<NatsClient>`) so it can accept the pre-named logger instance rather than deriving its category from its own type.
|
||||
|
||||
### Log flushing
|
||||
|
||||
`Log.CloseAndFlush()` runs in the `finally` block of `Program.cs` after the server stops, ensuring buffered log entries are written before the process exits:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
await server.StartAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Operations Overview](../Operations/Overview.md)
|
||||
- [Server Overview](../Server/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
197
Documentation/GettingStarted/Architecture.md
Normal file
197
Documentation/GettingStarted/Architecture.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the overall architecture of the NATS .NET server — its layers, component responsibilities, message flow, and the mapping from the Go reference implementation.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project is a port of the [NATS server](https://github.com/nats-io/nats-server) (`golang/nats-server/`) to .NET 10 / C#. The Go source in `golang/nats-server/server/` is the authoritative reference.
|
||||
|
||||
Current scope: base publish-subscribe server with wildcard subject matching and queue groups. Authentication, clustering (routes, gateways, leaf nodes), JetStream, and HTTP monitoring are not yet implemented.
|
||||
|
||||
---
|
||||
|
||||
## Solution Layout
|
||||
|
||||
```
|
||||
NatsDotNet.slnx
|
||||
src/
|
||||
NATS.Server/ # Core server library — no executable entry point
|
||||
NATS.Server.Host/ # Console application — wires logging, parses CLI args, starts server
|
||||
tests/
|
||||
NATS.Server.Tests/ # xUnit test project — unit and integration tests
|
||||
```
|
||||
|
||||
`NATS.Server` depends only on `Microsoft.Extensions.Logging.Abstractions`. All Serilog wiring is in `NATS.Server.Host`. This keeps the core library testable without a console host.
|
||||
|
||||
---
|
||||
|
||||
## Layers
|
||||
|
||||
The implementation is organized bottom-up: each layer depends only on the layers below it.
|
||||
|
||||
```
|
||||
NatsServer (orchestrator: accept loop, client registry, message routing)
|
||||
└── NatsClient (per-connection: I/O pipeline, command dispatch, subscription tracking)
|
||||
└── NatsParser (protocol state machine: bytes → ParsedCommand)
|
||||
└── SubList (trie: subject → matching subscriptions)
|
||||
└── SubjectMatch (validation and wildcard matching primitives)
|
||||
```
|
||||
|
||||
### SubjectMatch and SubList
|
||||
|
||||
`SubjectMatch` (`Subscriptions/SubjectMatch.cs`) provides the primitive operations used throughout the subscription system:
|
||||
|
||||
- `IsValidSubject(string)` — rejects empty tokens, whitespace, tokens after `>`
|
||||
- `IsLiteral(string)` — returns `false` if the subject contains a bare `*` or `>` wildcard token
|
||||
- `IsValidPublishSubject(string)` — combines both; publish subjects must be literal
|
||||
- `MatchLiteral(string literal, string pattern)` — token-by-token matching for cache maintenance
|
||||
|
||||
`SubList` (`Subscriptions/SubList.cs`) is a trie-based subscription store. Each trie level (`TrieLevel`) holds a `Dictionary<string, TrieNode>` for literal tokens plus dedicated `Pwc` and `Fwc` pointers for `*` and `>` wildcards. Each `TrieNode` holds `PlainSubs` (a `HashSet<Subscription>`) and `QueueSubs` (a `Dictionary<string, HashSet<Subscription>>` keyed by queue group name).
|
||||
|
||||
`SubList.Match(string subject)` checks an in-memory cache first, then falls back to a recursive trie walk (`MatchLevel`) if there is a cache miss. The result is stored as a `SubListResult` — an immutable snapshot containing `PlainSubs` (array) and `QueueSubs` (jagged array, one sub-array per group).
|
||||
|
||||
The cache holds up to 1,024 entries. When that limit is exceeded, 256 entries are swept. Wildcard subscriptions invalidate all matching cache keys on insert and removal; literal subscriptions invalidate only their own key.
|
||||
|
||||
### NatsParser
|
||||
|
||||
`NatsParser` (`Protocol/NatsParser.cs`) is a stateless-per-invocation parser that operates on `ReadOnlySequence<byte>` from `System.IO.Pipelines`. The public entry point is:
|
||||
|
||||
```csharp
|
||||
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
|
||||
```
|
||||
|
||||
It reads one control line at a time (up to `NatsProtocol.MaxControlLineSize` = 4,096 bytes), identifies the command by the first two lowercased bytes, then dispatches to a per-command parse method. Commands with payloads (`PUB`, `HPUB`) set `_awaitingPayload = true` and return on the next call via `TryReadPayload` once enough bytes arrive. This handles TCP fragmentation without buffering the entire payload before parsing begins.
|
||||
|
||||
`ParsedCommand` is a `readonly struct` — zero heap allocation for control-only commands (`PING`, `PONG`, `SUB`, `UNSUB`, `CONNECT`). Payload commands allocate a `byte[]` for the payload body.
|
||||
|
||||
Command dispatch in `NatsClient.DispatchCommandAsync` covers: `Connect`, `Ping`/`Pong`, `Sub`, `Unsub`, `Pub`, `HPub`.
|
||||
|
||||
### NatsClient
|
||||
|
||||
`NatsClient` (`NatsClient.cs`) handles a single TCP connection. On `RunAsync`, it sends the initial `INFO` frame and then starts two concurrent tasks:
|
||||
|
||||
```csharp
|
||||
var fillTask = FillPipeAsync(pipe.Writer, ct); // socket → PipeWriter
|
||||
var processTask = ProcessCommandsAsync(pipe.Reader, ct); // PipeReader → parser → dispatch
|
||||
```
|
||||
|
||||
`FillPipeAsync` reads from the `NetworkStream` into a `PipeWriter` in 4,096-byte chunks. `ProcessCommandsAsync` reads from the `PipeReader`, calls `NatsParser.TryParse` in a loop, and dispatches each `ParsedCommand`. The tasks share a `Pipe` instance from `System.IO.Pipelines`. Either task completing (EOF, cancellation, or error) causes `RunAsync` to return, which triggers cleanup via `Router.RemoveClient(this)`.
|
||||
|
||||
Write serialization uses a `SemaphoreSlim(1,1)` (`_writeLock`). All outbound writes (`SendMessageAsync`, `WriteAsync`) acquire this lock before touching the `NetworkStream`, preventing interleaved writes from concurrent message deliveries.
|
||||
|
||||
Subscription state is a `Dictionary<string, Subscription>` keyed by SID. This dictionary is accessed only from the single processing task, so no locking is needed. `SUB` inserts into this dictionary and into `SubList`; `UNSUB` either sets `MaxMessages` for auto-unsubscribe or immediately removes from both.
|
||||
|
||||
`NatsClient` exposes two interfaces to `NatsServer`:
|
||||
|
||||
```csharp
|
||||
public interface IMessageRouter
|
||||
{
|
||||
void ProcessMessage(string subject, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, NatsClient sender);
|
||||
void RemoveClient(NatsClient client);
|
||||
}
|
||||
|
||||
public interface ISubListAccess
|
||||
{
|
||||
SubList SubList { get; }
|
||||
}
|
||||
```
|
||||
|
||||
`NatsServer` implements both. `NatsClient.Router` is set to the server instance immediately after construction.
|
||||
|
||||
### NatsServer
|
||||
|
||||
`NatsServer` (`NatsServer.cs`) owns the TCP listener, the shared `SubList`, and the client registry. Its `StartAsync` method runs the accept loop:
|
||||
|
||||
```csharp
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_listener.Bind(new IPEndPoint(
|
||||
_options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
|
||||
_options.Port));
|
||||
_listener.Listen(128);
|
||||
// ...
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var socket = await _listener.AcceptAsync(ct);
|
||||
// create NatsClient, fire-and-forget RunClientAsync
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each accepted connection gets a unique `clientId` (incremented via `Interlocked.Increment`), a scoped logger, and a `NatsClient` instance registered in `_clients` (`ConcurrentDictionary<ulong, NatsClient>`). `RunClientAsync` is fired as a detached task — the accept loop does not await it.
|
||||
|
||||
Message delivery happens in `ProcessMessage`:
|
||||
|
||||
1. Call `_subList.Match(subject)` to get a `SubListResult`.
|
||||
2. Iterate `result.PlainSubs` — deliver to each subscriber, skipping the sender if `echo` is false.
|
||||
3. Iterate `result.QueueSubs` — for each group, use round-robin (modulo `Interlocked.Increment`) to pick one member to receive the message.
|
||||
|
||||
Delivery via `SendMessageAsync` is fire-and-forget (not awaited) to avoid blocking the publishing client's processing task while waiting for slow subscribers.
|
||||
|
||||
---
|
||||
|
||||
## Message Flow
|
||||
|
||||
```
|
||||
Client sends: PUB orders.new 12\r\nhello world\r\n
|
||||
|
||||
1. FillPipeAsync reads bytes from socket → PipeWriter
|
||||
2. ProcessCommandsAsync reads from PipeReader
|
||||
3. NatsParser.TryParse parses control line "PUB orders.new 12\r\n"
|
||||
sets _awaitingPayload = true
|
||||
on next call: reads payload + \r\n trailer
|
||||
returns ParsedCommand { Type=Pub, Subject="orders.new", Payload=[...] }
|
||||
4. DispatchCommandAsync calls ProcessPub(cmd)
|
||||
5. ProcessPub calls Router.ProcessMessage("orders.new", null, default, payload, this)
|
||||
6. NatsServer.ProcessMessage
|
||||
→ _subList.Match("orders.new")
|
||||
returns SubListResult { PlainSubs=[sub1, sub2], QueueSubs=[[sub3, sub4]] }
|
||||
→ DeliverMessage(sub1, ...) → sub1.Client.SendMessageAsync(...)
|
||||
→ DeliverMessage(sub2, ...) → sub2.Client.SendMessageAsync(...)
|
||||
→ round-robin pick from [sub3, sub4], e.g. sub3
|
||||
→ DeliverMessage(sub3, ...) → sub3.Client.SendMessageAsync(...)
|
||||
7. SendMessageAsync acquires _writeLock, writes MSG frame to socket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Go Reference Mapping
|
||||
|
||||
| Go source | .NET source |
|
||||
|-----------|-------------|
|
||||
| `server/sublist.go` | `src/NATS.Server/Subscriptions/SubList.cs` |
|
||||
| `server/parser.go` | `src/NATS.Server/Protocol/NatsParser.cs` |
|
||||
| `server/client.go` | `src/NATS.Server/NatsClient.cs` |
|
||||
| `server/server.go` | `src/NATS.Server/NatsServer.cs` |
|
||||
| `server/opts.go` | `src/NATS.Server/NatsOptions.cs` |
|
||||
|
||||
The Go `sublist.go` uses atomic generation counters to invalidate a result cache. The .NET `SubList` uses a different strategy: it maintains the cache under `ReaderWriterLockSlim` and does targeted invalidation at insert/remove time, avoiding the need for generation counters.
|
||||
|
||||
The Go `client.go` uses goroutines for `readLoop` and `writeLoop`. The .NET equivalent uses `async Task` with `System.IO.Pipelines`: `FillPipeAsync` (writer side) and `ProcessCommandsAsync` (reader side).
|
||||
|
||||
---
|
||||
|
||||
## Key .NET Design Choices
|
||||
|
||||
| Concern | .NET choice | Reason |
|
||||
|---------|-------------|--------|
|
||||
| I/O buffering | `System.IO.Pipelines` (`Pipe`, `PipeReader`, `PipeWriter`) | Zero-copy buffer management; backpressure built in |
|
||||
| SubList thread safety | `ReaderWriterLockSlim` | Multiple concurrent readers (match), exclusive writers (insert/remove) |
|
||||
| Client registry | `ConcurrentDictionary<ulong, NatsClient>` | Lock-free concurrent access from accept loop and cleanup tasks |
|
||||
| Write serialization | `SemaphoreSlim(1,1)` per client | Prevents interleaved MSG frames from concurrent deliveries |
|
||||
| Concurrency | `async/await` + `Task` | Maps Go goroutines to .NET task-based async; no dedicated threads per connection |
|
||||
| Protocol constants | `NatsProtocol` static class | Pre-encoded byte arrays (`PongBytes`, `CrLf`, etc.) avoid per-call allocations |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Setup](./Setup.md)
|
||||
- [Protocol Overview](../Protocol/Overview.md)
|
||||
- [Subscriptions Overview](../Subscriptions/Overview.md)
|
||||
- [Server Overview](../Server/Overview.md)
|
||||
- [Configuration Overview](../Configuration/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
183
Documentation/GettingStarted/Setup.md
Normal file
183
Documentation/GettingStarted/Setup.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Setup
|
||||
|
||||
This guide covers prerequisites, building, running, and testing the NATS .NET server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### .NET 10 SDK
|
||||
|
||||
The project targets `net10.0` with `LangVersion preview` (configured in `Directory.Build.props`). Install the .NET 10 preview SDK from https://dotnet.microsoft.com/download.
|
||||
|
||||
Verify your installation:
|
||||
|
||||
```bash
|
||||
dotnet --version
|
||||
# Expected: 10.0.x
|
||||
```
|
||||
|
||||
### Go Toolchain (optional)
|
||||
|
||||
The Go toolchain is only needed if you want to run the reference server from `golang/nats-server/` or execute Go test suites for cross-validation. It is not required for building or testing the .NET port.
|
||||
|
||||
```bash
|
||||
go version
|
||||
# Expected: go1.22 or later
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
The solution file uses the `.slnx` format (not `.sln`). Pass it explicitly if your tooling requires it.
|
||||
|
||||
```bash
|
||||
# Build all projects
|
||||
dotnet build
|
||||
|
||||
# Clean and rebuild
|
||||
dotnet clean && dotnet build
|
||||
```
|
||||
|
||||
The solution contains three projects:
|
||||
|
||||
| Project | Path |
|
||||
|---------|------|
|
||||
| `NATS.Server` | `src/NATS.Server/` |
|
||||
| `NATS.Server.Host` | `src/NATS.Server.Host/` |
|
||||
| `NATS.Server.Tests` | `tests/NATS.Server.Tests/` |
|
||||
|
||||
All projects share settings from `Directory.Build.props`: `net10.0` target framework, nullable reference types enabled, warnings treated as errors.
|
||||
|
||||
---
|
||||
|
||||
## Running
|
||||
|
||||
The server executable is `NATS.Server.Host`. With no arguments it binds to `0.0.0.0:4222`.
|
||||
|
||||
```bash
|
||||
dotnet run --project src/NATS.Server.Host
|
||||
```
|
||||
|
||||
### CLI Arguments
|
||||
|
||||
| Flag | Alias | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| `-p` | `--port` | `int` | `4222` | TCP port to listen on |
|
||||
| `-a` | `--addr` | `string` | `0.0.0.0` | Bind address |
|
||||
| `-n` | `--name` | `string` | `nats-dotnet-<hostname>` | Server name reported in INFO |
|
||||
|
||||
```bash
|
||||
# Custom port
|
||||
dotnet run --project src/NATS.Server.Host -- -p 14222
|
||||
|
||||
# Custom address and name
|
||||
dotnet run --project src/NATS.Server.Host -- -a 127.0.0.1 -p 4222 -n dev-server
|
||||
```
|
||||
|
||||
The `--` separator is required to pass arguments through `dotnet run` to the application.
|
||||
|
||||
Startup log output (Serilog console sink):
|
||||
|
||||
```
|
||||
[12:00:00 INF] Listening on 0.0.0.0:4222
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# 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 by name
|
||||
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListTests"
|
||||
```
|
||||
|
||||
### Test Stack
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `xunit` | 2.9.3 | Test framework |
|
||||
| `xunit.runner.visualstudio` | 3.1.4 | VS/Rider test runner integration |
|
||||
| `Shouldly` | 4.3.0 | Assertion library |
|
||||
| `NSubstitute` | 5.3.0 | Mocking |
|
||||
| `NATS.Client.Core` | 2.7.2 | Official NATS .NET client for integration tests |
|
||||
| `coverlet.collector` | 6.0.4 | Code coverage |
|
||||
|
||||
Do not use FluentAssertions or Moq — the project uses Shouldly and NSubstitute exclusively.
|
||||
|
||||
### Test Files
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| `ParserTests.cs` | `NatsParser.TryParse` for each command type |
|
||||
| `SubjectMatchTests.cs` | `SubjectMatch` validation and wildcard matching |
|
||||
| `SubListTests.cs` | `SubList` trie insert, remove, match, and cache behaviour |
|
||||
| `ClientTests.cs` | `NatsClient` command dispatch and subscription tracking |
|
||||
| `ServerTests.cs` | `NatsServer` pub/sub, wildcards, queue groups |
|
||||
| `IntegrationTests.cs` | End-to-end tests using `NATS.Client.Core` against a live server |
|
||||
|
||||
---
|
||||
|
||||
## NuGet: Central Package Management
|
||||
|
||||
Package versions are defined centrally in `Directory.Packages.props`. Individual `.csproj` files reference packages without a `Version` attribute:
|
||||
|
||||
```xml
|
||||
<!-- Directory.Packages.props -->
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- NATS.Server.Tests.csproj -->
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
```
|
||||
|
||||
To add a new dependency: add a `<PackageVersion>` entry in `Directory.Packages.props`, then add a `<PackageReference>` (without `Version`) in the relevant `.csproj`.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
The host configures Serilog via `Microsoft.Extensions.Logging` in `Program.cs`:
|
||||
|
||||
```csharp
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
var server = new NatsServer(options, loggerFactory);
|
||||
```
|
||||
|
||||
`NatsServer` and `NatsClient` receive `ILoggerFactory` and `ILogger` respectively via constructor injection. The core library (`NATS.Server`) depends only on `Microsoft.Extensions.Logging.Abstractions` — it has no direct Serilog dependency. The Serilog packages are wired in `NATS.Server.Host`.
|
||||
|
||||
Per-client loggers are created with a scoped category name that includes the client ID:
|
||||
|
||||
```csharp
|
||||
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
|
||||
```
|
||||
|
||||
To adjust log levels at runtime, modify the `LoggerConfiguration` in `Program.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Architecture](./Architecture.md)
|
||||
- [Configuration Overview](../Configuration/Overview.md)
|
||||
- [Protocol Overview](../Protocol/Overview.md)
|
||||
- [Server Overview](../Server/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
193
Documentation/Operations/Overview.md
Normal file
193
Documentation/Operations/Overview.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Operations Overview
|
||||
|
||||
This document covers running the server, graceful shutdown, connecting clients, and the test suite.
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
The host application is `src/NATS.Server.Host`. Run it with `dotnet run`:
|
||||
|
||||
```bash
|
||||
# Default (port 4222, bind 0.0.0.0)
|
||||
dotnet run --project src/NATS.Server.Host
|
||||
|
||||
# Custom port
|
||||
dotnet run --project src/NATS.Server.Host -- -p 14222
|
||||
|
||||
# Custom bind address and name
|
||||
dotnet run --project src/NATS.Server.Host -- -a 127.0.0.1 -n my-server
|
||||
```
|
||||
|
||||
Arguments after `--` are passed to the program. The three supported flags are `-p`/`--port`, `-a`/`--addr`, and `-n`/`--name`. See [Configuration Overview](../Configuration/Overview.md) for the full CLI reference.
|
||||
|
||||
On startup, the server logs the address it is listening on:
|
||||
|
||||
```
|
||||
[14:32:01 INF] Listening on 0.0.0.0:4222
|
||||
```
|
||||
|
||||
### Full host setup
|
||||
|
||||
`Program.cs` initializes Serilog, parses CLI arguments, starts the server, and handles graceful shutdown:
|
||||
|
||||
```csharp
|
||||
using NATS.Server;
|
||||
using Serilog;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
var options = new NatsOptions();
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "-p" or "--port" when i + 1 < args.Length:
|
||||
options.Port = int.Parse(args[++i]);
|
||||
break;
|
||||
case "-a" or "--addr" when i + 1 < args.Length:
|
||||
options.Host = args[++i];
|
||||
break;
|
||||
case "-n" or "--name" when i + 1 < args.Length:
|
||||
options.ServerName = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
var server = new NatsServer(options, loggerFactory);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await server.StartAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
Pressing Ctrl+C triggers `Console.CancelKeyPress`. The handler sets `e.Cancel = true` — this prevents the process from terminating immediately — and calls `cts.Cancel()` to signal the `CancellationToken` passed to `server.StartAsync`.
|
||||
|
||||
`NatsServer.StartAsync` exits its accept loop on cancellation. In-flight client connections are left to drain naturally. After `StartAsync` returns (via `OperationCanceledException` which is caught), the `finally` block runs `Log.CloseAndFlush()` to ensure all buffered log output is written before the process exits.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run the full test suite with:
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
The test project is at `tests/NATS.Server.Tests/`. It uses xUnit with Shouldly for assertions.
|
||||
|
||||
### Test summary
|
||||
|
||||
69 tests across 6 test files:
|
||||
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `SubjectMatchTests.cs` | 33 | Subject validation and wildcard matching |
|
||||
| `SubListTests.cs` | 12 | Trie insert, remove, match, queue groups, cache |
|
||||
| `ParserTests.cs` | 14 | All command types, split packets, case insensitivity |
|
||||
| `ClientTests.cs` | 2 | Socket-level INFO on connect, PING/PONG |
|
||||
| `ServerTests.cs` | 3 | End-to-end accept, pub/sub, wildcard delivery |
|
||||
| `IntegrationTests.cs` | 5 | NATS.Client.Core protocol compatibility |
|
||||
|
||||
### Test categories
|
||||
|
||||
**SubjectMatchTests** — 33 `[Theory]` cases verifying `SubjectMatch.IsValidSubject` (16 cases), `SubjectMatch.IsValidPublishSubject` (6 cases), and `SubjectMatch.MatchLiteral` (11 cases). Covers empty strings, leading/trailing dots, embedded spaces, `>` in non-terminal position, and all wildcard combinations.
|
||||
|
||||
**SubListTests** — 12 `[Fact]` tests exercising the `SubList` trie directly: literal insert and match, empty result, `*` wildcard at various token levels, `>` wildcard, root `>`, multiple overlapping subscriptions, remove, queue group grouping, `Count` tracking, and cache invalidation after a wildcard insert.
|
||||
|
||||
**ParserTests** — 14 `async [Fact]` tests that write protocol bytes into a `Pipe` and assert on the resulting `ParsedCommand` list. Covers `PING`, `PONG`, `CONNECT`, `SUB` (with and without queue group), `UNSUB` (with and without `max-messages`), `PUB` (with payload, with reply-to, zero payload), `HPUB` (with header), `INFO`, multiple commands in a single buffer, and case-insensitive parsing.
|
||||
|
||||
**ClientTests** — 2 `async [Fact]` tests using a real loopback socket pair. Verifies that `NatsClient` sends an `INFO` frame immediately on connection, and that it responds `PONG` to a `PING` after `CONNECT`.
|
||||
|
||||
**ServerTests** — 3 `async [Fact]` tests that start `NatsServer` on a random port. Verifies `INFO` on connect, basic pub/sub delivery (`MSG` format), and wildcard subscription matching.
|
||||
|
||||
**IntegrationTests** — 5 `async [Fact]` tests using the official `NATS.Client.Core` v2.7.2 NuGet package. Verifies end-to-end protocol compatibility with a real NATS client library: basic pub/sub, `*` wildcard delivery, `>` wildcard delivery, fan-out to two subscribers, and `PingAsync`.
|
||||
|
||||
Integration tests use `NullLoggerFactory.Instance` for the server so test output is not cluttered with server logs.
|
||||
|
||||
---
|
||||
|
||||
## Connecting with Standard NATS Clients
|
||||
|
||||
The server speaks the standard NATS wire protocol. Any NATS client library can connect to it.
|
||||
|
||||
```bash
|
||||
# Using the nats CLI tool
|
||||
nats sub "test.>" --server nats://127.0.0.1:4222
|
||||
nats pub test.hello "world" --server nats://127.0.0.1:4222
|
||||
```
|
||||
|
||||
From .NET, using `NATS.Client.Core`:
|
||||
|
||||
```csharp
|
||||
var opts = new NatsOpts { Url = "nats://127.0.0.1:4222" };
|
||||
await using var conn = new NatsConnection(opts);
|
||||
await conn.ConnectAsync();
|
||||
await conn.PublishAsync("test.hello", "world");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Go Reference Server
|
||||
|
||||
The Go reference implementation can be built from `golang/nats-server/` for comparison testing:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cd golang/nats-server && go build
|
||||
|
||||
# Run on default port
|
||||
./nats-server
|
||||
|
||||
# Run on custom port
|
||||
./nats-server -p 14222
|
||||
```
|
||||
|
||||
The Go server is useful for verifying that the .NET port produces identical protocol output and behaves the same way under edge-case client interactions.
|
||||
|
||||
---
|
||||
|
||||
## Current Limitations
|
||||
|
||||
The following features present in the Go reference server are not yet ported:
|
||||
|
||||
- Authentication — no username/password, token, NKey, or JWT support
|
||||
- Clustering — no routes, gateways, or leaf nodes
|
||||
- JetStream — no persistent streaming, streams, consumers, or RAFT
|
||||
- Monitoring — no HTTP endpoints (`/varz`, `/connz`, `/healthz`, etc.)
|
||||
- TLS — all connections are plaintext
|
||||
- WebSocket — no WebSocket transport
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Configuration Overview](../Configuration/Overview.md)
|
||||
- [Server Overview](../Server/Overview.md)
|
||||
- [Protocol Overview](../Protocol/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
151
Documentation/Protocol/Overview.md
Normal file
151
Documentation/Protocol/Overview.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Protocol Overview
|
||||
|
||||
NATS uses a line-oriented, text-based protocol over TCP. All commands are terminated by `\r\n`. This simplicity makes it easy to debug with raw TCP tools and keeps parsing overhead low.
|
||||
|
||||
## Command Reference
|
||||
|
||||
All commands flow either from server to client (S→C) or client to server (C→S). PING and PONG travel in both directions as part of the keepalive mechanism.
|
||||
|
||||
| Command | Direction | Format |
|
||||
|---------|-----------|--------|
|
||||
| INFO | S→C | `INFO {json}\r\n` |
|
||||
| CONNECT | C→S | `CONNECT {json}\r\n` |
|
||||
| PUB | C→S | `PUB subject [reply] size\r\n[payload]\r\n` |
|
||||
| HPUB | C→S | `HPUB subject [reply] hdr_size total_size\r\n[headers+payload]\r\n` |
|
||||
| SUB | C→S | `SUB subject [queue] sid\r\n` |
|
||||
| UNSUB | C→S | `UNSUB sid [max_msgs]\r\n` |
|
||||
| MSG | S→C | `MSG subject sid [reply] size\r\n[payload]\r\n` |
|
||||
| HMSG | S→C | `HMSG subject sid [reply] hdr_size total_size\r\n[headers+payload]\r\n` |
|
||||
| PING/PONG | Both | `PING\r\n` / `PONG\r\n` |
|
||||
| +OK/-ERR | S→C | `+OK\r\n` / `-ERR 'msg'\r\n` |
|
||||
|
||||
Arguments in brackets are optional. `sid` is the subscription ID string assigned by the client. Commands with a payload body (PUB, HPUB, MSG, HMSG) use a two-line structure: a control line with sizes, then the raw payload bytes, then a terminating `\r\n`.
|
||||
|
||||
## Connection Handshake
|
||||
|
||||
The handshake is always server-initiated:
|
||||
|
||||
1. Client opens TCP connection.
|
||||
2. Server immediately sends `INFO {json}\r\n` describing itself.
|
||||
3. Client sends `CONNECT {json}\r\n` with its options.
|
||||
4. Normal operation begins (PUB, SUB, MSG, PING/PONG, etc.).
|
||||
|
||||
If `verbose` is enabled in `ClientOptions`, the server sends `+OK` after each valid client command. If the server rejects the CONNECT (bad credentials, unsupported protocol version, etc.) it sends `-ERR 'message'\r\n` and closes the connection.
|
||||
|
||||
## ServerInfo
|
||||
|
||||
The `ServerInfo` JSON payload is sent in the initial INFO message. The `ClientId` and `ClientIp` fields are omitted from JSON when not set.
|
||||
|
||||
```csharp
|
||||
public sealed class ServerInfo
|
||||
{
|
||||
[JsonPropertyName("server_id")]
|
||||
public required string ServerId { get; set; }
|
||||
|
||||
[JsonPropertyName("server_name")]
|
||||
public required string ServerName { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; set; }
|
||||
|
||||
[JsonPropertyName("proto")]
|
||||
public int Proto { get; set; } = NatsProtocol.ProtoVersion;
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public required string Host { get; set; }
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("headers")]
|
||||
public bool Headers { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("max_payload")]
|
||||
public int MaxPayload { get; set; } = NatsProtocol.MaxPayloadSize;
|
||||
|
||||
[JsonPropertyName("client_id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public ulong ClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("client_ip")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ClientIp { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
`headers` signals that the server supports HPUB/HMSG. `max_payload` advertises the largest message body the server accepts (default 1 MB). `proto` is the protocol version integer; the current value is `1`.
|
||||
|
||||
## ClientOptions
|
||||
|
||||
The `ClientOptions` JSON payload is sent by the client in the CONNECT command.
|
||||
|
||||
```csharp
|
||||
public sealed class ClientOptions
|
||||
{
|
||||
[JsonPropertyName("verbose")]
|
||||
public bool Verbose { get; set; }
|
||||
|
||||
[JsonPropertyName("pedantic")]
|
||||
public bool Pedantic { get; set; }
|
||||
|
||||
[JsonPropertyName("echo")]
|
||||
public bool Echo { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public string? Lang { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("protocol")]
|
||||
public int Protocol { get; set; }
|
||||
|
||||
[JsonPropertyName("headers")]
|
||||
public bool Headers { get; set; }
|
||||
|
||||
[JsonPropertyName("no_responders")]
|
||||
public bool NoResponders { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
`echo` defaults to `true`, meaning a client receives its own published messages if it has a matching subscription. Setting `echo` to `false` suppresses that. `no_responders` causes the server to send a status message when a request has no subscribers, rather than letting the client time out.
|
||||
|
||||
## Protocol Constants
|
||||
|
||||
`NatsProtocol` centralises limits and pre-encoded byte arrays to avoid repeated allocations in the hot path.
|
||||
|
||||
```csharp
|
||||
public static class NatsProtocol
|
||||
{
|
||||
public const int MaxControlLineSize = 4096;
|
||||
public const int MaxPayloadSize = 1024 * 1024; // 1MB
|
||||
public const int DefaultPort = 4222;
|
||||
|
||||
// Pre-encoded protocol fragments
|
||||
public static readonly byte[] CrLf = "\r\n"u8.ToArray();
|
||||
public static readonly byte[] PingBytes = "PING\r\n"u8.ToArray();
|
||||
public static readonly byte[] PongBytes = "PONG\r\n"u8.ToArray();
|
||||
public static readonly byte[] OkBytes = "+OK\r\n"u8.ToArray();
|
||||
public static readonly byte[] InfoPrefix = "INFO "u8.ToArray();
|
||||
public static readonly byte[] MsgPrefix = "MSG "u8.ToArray();
|
||||
public static readonly byte[] HmsgPrefix = "HMSG "u8.ToArray();
|
||||
public static readonly byte[] ErrPrefix = "-ERR "u8.ToArray();
|
||||
}
|
||||
```
|
||||
|
||||
`MaxControlLineSize` (4096 bytes) is the maximum length of a command line before the payload. Any control line that exceeds this limit causes the parser to throw `ProtocolViolationException`. `MaxPayloadSize` (1 MB) is the default limit enforced by the parser; it is configurable per server instance.
|
||||
|
||||
## Go Reference
|
||||
|
||||
The Go implementation of protocol parsing is in `golang/nats-server/server/parser.go`. The .NET implementation follows the same command identification strategy and enforces the same control line and payload size limits.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Parser](Parser.md)
|
||||
- [Server Overview](../Server/Overview.md)
|
||||
- [Configuration Overview](../Configuration/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
271
Documentation/Protocol/Parser.md
Normal file
271
Documentation/Protocol/Parser.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Parser
|
||||
|
||||
`NatsParser` is a stateful byte-level parser that processes NATS protocol commands from a `ReadOnlySequence<byte>` provided by `System.IO.Pipelines`. It is called repeatedly in a read loop until no more complete commands are available in the buffer.
|
||||
|
||||
## Key Types
|
||||
|
||||
### CommandType
|
||||
|
||||
The `CommandType` enum identifies every command the parser can produce:
|
||||
|
||||
```csharp
|
||||
public enum CommandType
|
||||
{
|
||||
Ping,
|
||||
Pong,
|
||||
Connect,
|
||||
Info,
|
||||
Pub,
|
||||
HPub,
|
||||
Sub,
|
||||
Unsub,
|
||||
Ok,
|
||||
Err,
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
```csharp
|
||||
public readonly struct ParsedCommand
|
||||
{
|
||||
public CommandType Type { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? ReplyTo { get; init; }
|
||||
public string? Queue { get; init; }
|
||||
public string? Sid { get; init; }
|
||||
public int MaxMessages { get; init; }
|
||||
public int HeaderSize { get; init; }
|
||||
public ReadOnlyMemory<byte> Payload { get; init; }
|
||||
|
||||
public static ParsedCommand Simple(CommandType type) => new() { Type = type, MaxMessages = -1 };
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
`TryParse` is the main entry point. It is called by the read loop after each `PipeReader.ReadAsync` completes.
|
||||
|
||||
```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.
|
||||
|
||||
If the parser detects a malformed command it throws `ProtocolViolationException`, which the read loop catches to close the connection.
|
||||
|
||||
## Command Identification
|
||||
|
||||
After locating the `\r\n` control line terminator, the parser lowercase-normalises the first two bytes using a bitwise OR with `0x20` and dispatches on them. This single branch handles both upper- and lowercase input without a string comparison or allocation.
|
||||
|
||||
```csharp
|
||||
byte b0 = (byte)(lineSpan[0] | 0x20); // lowercase
|
||||
byte b1 = (byte)(lineSpan[1] | 0x20);
|
||||
|
||||
switch (b0)
|
||||
{
|
||||
case (byte)'p':
|
||||
if (b1 == (byte)'i') // PING
|
||||
{
|
||||
command = ParsedCommand.Simple(CommandType.Ping);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (b1 == (byte)'o') // PONG
|
||||
{
|
||||
command = ParsedCommand.Simple(CommandType.Pong);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (b1 == (byte)'u') // PUB
|
||||
{
|
||||
return ParsePub(lineSpan, ref buffer, reader.Position, out command);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case (byte)'h':
|
||||
if (b1 == (byte)'p') // HPUB
|
||||
{
|
||||
return ParseHPub(lineSpan, ref buffer, reader.Position, out command);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case (byte)'s':
|
||||
if (b1 == (byte)'u') // SUB
|
||||
{
|
||||
command = ParseSub(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case (byte)'u':
|
||||
if (b1 == (byte)'n') // UNSUB
|
||||
{
|
||||
command = ParseUnsub(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case (byte)'c':
|
||||
if (b1 == (byte)'o') // CONNECT
|
||||
{
|
||||
command = ParseConnect(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case (byte)'i':
|
||||
if (b1 == (byte)'n') // INFO
|
||||
{
|
||||
command = ParseInfo(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case (byte)'+': // +OK
|
||||
command = ParsedCommand.Simple(CommandType.Ok);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
|
||||
case (byte)'-': // -ERR
|
||||
command = ParsedCommand.Simple(CommandType.Err);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ProtocolViolationException("Unknown protocol operation");
|
||||
```
|
||||
|
||||
The two-character pairs are: `p+i` = PING, `p+o` = PONG, `p+u` = PUB, `h+p` = HPUB, `s+u` = SUB, `u+n` = UNSUB, `c+o` = CONNECT, `i+n` = INFO. `+` and `-` are matched on `b0` alone since their second characters are unambiguous.
|
||||
|
||||
## Two-Phase Parsing for PUB and HPUB
|
||||
|
||||
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 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`.
|
||||
|
||||
On the next call to `TryParse`, the check at the top of the method routes straight to `TryReadPayload` without re-parsing the control line:
|
||||
|
||||
```csharp
|
||||
if (_awaitingPayload)
|
||||
return TryReadPayload(ref buffer, out command);
|
||||
```
|
||||
|
||||
This means the parser correctly handles payloads that arrive across multiple `PipeReader.ReadAsync` completions without buffering the control line a second time.
|
||||
|
||||
## 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`:
|
||||
|
||||
```csharp
|
||||
internal static int SplitArgs(Span<byte> data, Span<Range> ranges)
|
||||
{
|
||||
int count = 0;
|
||||
int start = -1;
|
||||
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
byte b = data[i];
|
||||
if (b is (byte)' ' or (byte)'\t')
|
||||
{
|
||||
if (start >= 0)
|
||||
{
|
||||
if (count >= ranges.Length)
|
||||
throw new ProtocolViolationException("Too many arguments");
|
||||
ranges[count++] = start..i;
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (start < 0)
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (start >= 0)
|
||||
{
|
||||
if (count >= ranges.Length)
|
||||
throw new ProtocolViolationException("Too many arguments");
|
||||
ranges[count++] = start..data.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
```
|
||||
|
||||
The returned `int` is the number of populated entries in `ranges`. Callers index into the original span using those ranges (e.g. `argsSpan[ranges[0]]`) to extract each token as a sub-span, then decode to `string` with `Encoding.ASCII.GetString`. Consecutive whitespace is collapsed: a new token only begins on a non-whitespace byte after one or more whitespace bytes.
|
||||
|
||||
## Decimal Integer Parsing
|
||||
|
||||
`ParseSize` converts an ASCII decimal integer in a byte span to an `int`. It is used for payload sizes and UNSUB max-message counts.
|
||||
|
||||
```csharp
|
||||
internal static int ParseSize(Span<byte> data)
|
||||
{
|
||||
if (data.Length == 0 || data.Length > 9)
|
||||
return -1;
|
||||
int n = 0;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
if (b < (byte)'0' || b > (byte)'9')
|
||||
return -1;
|
||||
n = n * 10 + (b - '0');
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
```
|
||||
|
||||
The length cap of 9 digits prevents overflow without a checked-arithmetic check. A return value of `-1` signals a parse failure; callers treat this as a `ProtocolViolationException`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
`ProtocolViolationException` is thrown for all malformed input:
|
||||
|
||||
- Control line exceeds `MaxControlLineSize` (4096 bytes).
|
||||
- Unknown command bytes.
|
||||
- Wrong number of arguments for a command.
|
||||
- Payload size is negative, exceeds `MaxPayloadSize`, or the trailing `\r\n` after the payload is absent.
|
||||
- `SplitArgs` receives more tokens than the caller's `ranges` span can hold.
|
||||
|
||||
The read loop is responsible for catching `ProtocolViolationException`, sending `-ERR` to the client, and closing the connection.
|
||||
|
||||
## Limits
|
||||
|
||||
| Limit | Value | Source |
|
||||
|-------|-------|--------|
|
||||
| Max control line | 4096 bytes | `NatsProtocol.MaxControlLineSize` |
|
||||
| Max payload (default) | 1 048 576 bytes | `NatsProtocol.MaxPayloadSize` |
|
||||
| Max size field digits | 9 | `ParseSize` length check |
|
||||
|
||||
The max payload is configurable: `NatsParser` accepts a `maxPayload` constructor argument, which `NatsClient` sets from `NatsOptions`.
|
||||
|
||||
## Go Reference
|
||||
|
||||
The .NET parser is a direct port of the state machine in `golang/nats-server/server/parser.go`. The Go implementation uses the same two-byte command identification technique and the same two-phase control-line/payload split for PUB and HPUB.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Protocol Overview](Overview.md)
|
||||
- [Server Overview](../Server/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
375
Documentation/Server/Client.md
Normal file
375
Documentation/Server/Client.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Client Connection Handler
|
||||
|
||||
`NatsClient` manages the full lifecycle of one TCP connection: sending the initial INFO handshake, reading and parsing the incoming byte stream, dispatching protocol commands, and writing outbound messages. One `NatsClient` instance exists per accepted socket.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Fields and properties
|
||||
|
||||
```csharp
|
||||
public sealed class NatsClient : IDisposable
|
||||
{
|
||||
private readonly Socket _socket;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly NatsOptions _options;
|
||||
private readonly ServerInfo _serverInfo;
|
||||
private readonly NatsParser _parser;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly Dictionary<string, Subscription> _subs = new();
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ulong Id { get; }
|
||||
public ClientOptions? ClientOpts { get; private set; }
|
||||
public IMessageRouter? Router { get; set; }
|
||||
public bool ConnectReceived { get; private set; }
|
||||
|
||||
public long InMsgs;
|
||||
public long OutMsgs;
|
||||
public long InBytes;
|
||||
public long OutBytes;
|
||||
|
||||
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
|
||||
}
|
||||
```
|
||||
|
||||
`_writeLock` is a `SemaphoreSlim(1, 1)` — a binary semaphore that serializes all writes to `_stream`. Without it, concurrent `SendMessageAsync` calls from different publisher threads would interleave bytes on the wire. See [Write serialization](#write-serialization) below.
|
||||
|
||||
`_subs` maps subscription IDs (SIDs) to `Subscription` objects. SIDs are client-assigned strings; `Dictionary<string, Subscription>` gives O(1) lookup for UNSUB processing.
|
||||
|
||||
The four stat fields (`InMsgs`, `OutMsgs`, `InBytes`, `OutBytes`) are `long` fields accessed via `Interlocked` operations throughout the hot path. They are exposed as public fields rather than properties to allow `Interlocked.Increment` and `Interlocked.Add` directly by reference.
|
||||
|
||||
`Router` is set by `NatsServer` after construction, before `RunAsync` is called. It is typed as `IMessageRouter?` rather than `NatsServer` so that tests can substitute a stub.
|
||||
|
||||
### Constructor
|
||||
|
||||
```csharp
|
||||
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo, ILogger logger)
|
||||
{
|
||||
Id = id;
|
||||
_socket = socket;
|
||||
_stream = new NetworkStream(socket, ownsSocket: false);
|
||||
_options = options;
|
||||
_serverInfo = serverInfo;
|
||||
_logger = logger;
|
||||
_parser = new NatsParser(options.MaxPayload);
|
||||
}
|
||||
```
|
||||
|
||||
`NetworkStream` is created with `ownsSocket: false`. This keeps socket lifetime management in `NatsServer`, which disposes the socket explicitly in `Dispose`. If `ownsSocket` were `true`, disposing the `NetworkStream` would close the socket, potentially racing with the disposal path in `NatsServer`.
|
||||
|
||||
`NatsParser` is constructed with `MaxPayload` from options. The parser enforces this limit: a payload larger than `MaxPayload` causes a `ProtocolViolationException` and terminates the connection.
|
||||
|
||||
## Connection Lifecycle
|
||||
|
||||
### RunAsync
|
||||
|
||||
`RunAsync` is the single entry point for a connection. `NatsServer` calls it as a fire-and-forget task.
|
||||
|
||||
```csharp
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
try
|
||||
{
|
||||
await SendInfoAsync(ct);
|
||||
|
||||
var fillTask = FillPipeAsync(pipe.Writer, ct);
|
||||
var processTask = ProcessCommandsAsync(pipe.Reader, ct);
|
||||
|
||||
await Task.WhenAny(fillTask, processTask);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Router?.RemoveClient(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The method:
|
||||
|
||||
1. Sends `INFO {json}\r\n` immediately on connect — required by the NATS protocol before the client sends CONNECT.
|
||||
2. Creates a `System.IO.Pipelines.Pipe` and starts two concurrent tasks: `FillPipeAsync` reads bytes from the socket into the pipe's write end; `ProcessCommandsAsync` reads from the pipe's read end and dispatches commands.
|
||||
3. Awaits `Task.WhenAny`. Either task completing signals the connection is done — either the socket closed (fill task returns) or a protocol error caused the process task to throw.
|
||||
4. In `finally`, calls `Router?.RemoveClient(this)` to clean up subscriptions and remove the client from the server's client dictionary.
|
||||
|
||||
`Router?.RemoveClient(this)` uses a null-conditional because `Router` could be null if the client is used in a test context without a server.
|
||||
|
||||
### FillPipeAsync
|
||||
|
||||
```csharp
|
||||
private async Task FillPipeAsync(PipeWriter writer, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var memory = writer.GetMemory(4096);
|
||||
int bytesRead = await _stream.ReadAsync(memory, ct);
|
||||
if (bytesRead == 0)
|
||||
break;
|
||||
|
||||
writer.Advance(bytesRead);
|
||||
var result = await writer.FlushAsync(ct);
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await writer.CompleteAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`writer.GetMemory(4096)` requests at least 4096 bytes of buffer space from the pipe. The pipe may provide more. `_stream.ReadAsync` fills as many bytes as the OS delivers in one call. `writer.Advance(bytesRead)` commits those bytes. `writer.FlushAsync` makes them available to the reader.
|
||||
|
||||
When `bytesRead` is 0 the socket has closed. `writer.CompleteAsync()` in the `finally` block signals end-of-stream to the reader, which causes `ProcessCommandsAsync` to exit its loop on the next iteration.
|
||||
|
||||
### ProcessCommandsAsync
|
||||
|
||||
```csharp
|
||||
private async Task ProcessCommandsAsync(PipeReader reader, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var result = await reader.ReadAsync(ct);
|
||||
var buffer = result.Buffer;
|
||||
|
||||
while (_parser.TryParse(ref buffer, out var cmd))
|
||||
{
|
||||
await DispatchCommandAsync(cmd, ct);
|
||||
}
|
||||
|
||||
reader.AdvanceTo(buffer.Start, buffer.End);
|
||||
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await reader.CompleteAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`reader.ReadAsync` returns a `ReadResult` containing a `ReadOnlySequence<byte>`. The inner `while` loop calls `_parser.TryParse` repeatedly, which slices `buffer` forward past each complete command. When `TryParse` returns `false`, not enough data is available for a complete command.
|
||||
|
||||
`reader.AdvanceTo(buffer.Start, buffer.End)` uses the two-argument form: `buffer.Start` (the consumed position — data before this is discarded) and `buffer.End` (the examined position — the pipe knows to wake this task when more data arrives beyond this point). This is the standard `System.IO.Pipelines` backpressure pattern.
|
||||
|
||||
## Command Dispatch
|
||||
|
||||
`DispatchCommandAsync` switches on the `CommandType` returned by the parser:
|
||||
|
||||
```csharp
|
||||
private async ValueTask DispatchCommandAsync(ParsedCommand cmd, CancellationToken ct)
|
||||
{
|
||||
switch (cmd.Type)
|
||||
{
|
||||
case CommandType.Connect:
|
||||
ProcessConnect(cmd);
|
||||
break;
|
||||
|
||||
case CommandType.Ping:
|
||||
await WriteAsync(NatsProtocol.PongBytes, ct);
|
||||
break;
|
||||
|
||||
case CommandType.Pong:
|
||||
// Update RTT tracking (placeholder)
|
||||
break;
|
||||
|
||||
case CommandType.Sub:
|
||||
ProcessSub(cmd);
|
||||
break;
|
||||
|
||||
case CommandType.Unsub:
|
||||
ProcessUnsub(cmd);
|
||||
break;
|
||||
|
||||
case CommandType.Pub:
|
||||
case CommandType.HPub:
|
||||
ProcessPub(cmd);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CONNECT
|
||||
|
||||
`ProcessConnect` deserializes the JSON payload into a `ClientOptions` record and sets `ConnectReceived = true`. `ClientOptions` carries the `echo` flag (default `true`), the client name, language, and version strings.
|
||||
|
||||
### PING / PONG
|
||||
|
||||
PING is responded to immediately with the pre-allocated `NatsProtocol.PongBytes` (`"PONG\r\n"`). The response goes through `WriteAsync`, which acquires the write lock. PONG handling is currently a placeholder for future RTT tracking.
|
||||
|
||||
### SUB
|
||||
|
||||
```csharp
|
||||
private void ProcessSub(ParsedCommand cmd)
|
||||
{
|
||||
var sub = new Subscription
|
||||
{
|
||||
Subject = cmd.Subject!,
|
||||
Queue = cmd.Queue,
|
||||
Sid = cmd.Sid!,
|
||||
};
|
||||
|
||||
_subs[cmd.Sid!] = sub;
|
||||
sub.Client = this;
|
||||
|
||||
if (Router is ISubListAccess sl)
|
||||
sl.SubList.Insert(sub);
|
||||
}
|
||||
```
|
||||
|
||||
A `Subscription` is stored in `_subs` (keyed by SID) and inserted into the shared `SubList` trie. The `Client` back-reference on `Subscription` is set to `this` so that `NatsServer.ProcessMessage` can reach the client from the subscription without a separate lookup.
|
||||
|
||||
`Router is ISubListAccess sl` checks the interface at runtime. In production, `Router` is `NatsServer`, which implements both interfaces. In tests using a stub `IMessageRouter` that does not implement `ISubListAccess`, the insert is silently skipped.
|
||||
|
||||
### UNSUB
|
||||
|
||||
```csharp
|
||||
private void ProcessUnsub(ParsedCommand cmd)
|
||||
{
|
||||
if (!_subs.TryGetValue(cmd.Sid!, out var sub))
|
||||
return;
|
||||
|
||||
if (cmd.MaxMessages > 0)
|
||||
{
|
||||
sub.MaxMessages = cmd.MaxMessages;
|
||||
return;
|
||||
}
|
||||
|
||||
_subs.Remove(cmd.Sid!);
|
||||
|
||||
if (Router is ISubListAccess sl)
|
||||
sl.SubList.Remove(sub);
|
||||
}
|
||||
```
|
||||
|
||||
UNSUB has two modes:
|
||||
|
||||
- With `max_msgs > 0`: sets `sub.MaxMessages` to limit future deliveries. The subscription stays in the trie and the client's `_subs` dict. `DeliverMessage` in `NatsServer` checks `MessageCount` against `MaxMessages` on each delivery and silently drops messages beyond the limit.
|
||||
- Without `max_msgs` (or `max_msgs == 0`): removes the subscription immediately from both `_subs` and the `SubList`.
|
||||
|
||||
### PUB and HPUB
|
||||
|
||||
```csharp
|
||||
private void ProcessPub(ParsedCommand cmd)
|
||||
{
|
||||
Interlocked.Increment(ref InMsgs);
|
||||
Interlocked.Add(ref InBytes, cmd.Payload.Length);
|
||||
|
||||
ReadOnlyMemory<byte> headers = default;
|
||||
ReadOnlyMemory<byte> payload = cmd.Payload;
|
||||
|
||||
if (cmd.Type == CommandType.HPub && cmd.HeaderSize > 0)
|
||||
{
|
||||
headers = cmd.Payload[..cmd.HeaderSize];
|
||||
payload = cmd.Payload[cmd.HeaderSize..];
|
||||
}
|
||||
|
||||
Router?.ProcessMessage(cmd.Subject!, cmd.ReplyTo, headers, payload, this);
|
||||
}
|
||||
```
|
||||
|
||||
Stats are updated before routing. For HPUB, the combined payload from the parser is split into a header slice and a body slice using `cmd.HeaderSize`. Both slices are `ReadOnlyMemory<byte>` views over the same backing array — no copy. `Router.ProcessMessage` then delivers to all matching subscribers.
|
||||
|
||||
## Write Serialization
|
||||
|
||||
Multiple concurrent `SendMessageAsync` calls can arrive from different publisher connections at the same time. Without coordination, their writes would interleave on the socket and corrupt the message stream for the receiving client. `_writeLock` prevents this:
|
||||
|
||||
```csharp
|
||||
public async Task SendMessageAsync(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref OutMsgs);
|
||||
Interlocked.Add(ref OutBytes, payload.Length + headers.Length);
|
||||
|
||||
byte[] line;
|
||||
if (headers.Length > 0)
|
||||
{
|
||||
int totalSize = headers.Length + payload.Length;
|
||||
line = Encoding.ASCII.GetBytes(
|
||||
$"HMSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{headers.Length} {totalSize}\r\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
line = Encoding.ASCII.GetBytes(
|
||||
$"MSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{payload.Length}\r\n");
|
||||
}
|
||||
|
||||
await _writeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(line, ct);
|
||||
if (headers.Length > 0)
|
||||
await _stream.WriteAsync(headers, ct);
|
||||
if (payload.Length > 0)
|
||||
await _stream.WriteAsync(payload, ct);
|
||||
await _stream.WriteAsync(NatsProtocol.CrLf, ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The control line is constructed before acquiring the lock so the string formatting work happens outside the critical section. Once the lock is held, all writes for one message — control line, optional headers, payload, and trailing `\r\n` — happen atomically from the perspective of other writers.
|
||||
|
||||
Stats (`OutMsgs`, `OutBytes`) are updated before the lock because they are independent of the write ordering constraint.
|
||||
|
||||
## Subscription Cleanup
|
||||
|
||||
`RemoveAllSubscriptions` is called by `NatsServer.RemoveClient` when a connection ends:
|
||||
|
||||
```csharp
|
||||
public void RemoveAllSubscriptions(SubList subList)
|
||||
{
|
||||
foreach (var sub in _subs.Values)
|
||||
subList.Remove(sub);
|
||||
_subs.Clear();
|
||||
}
|
||||
```
|
||||
|
||||
This removes every subscription this client holds from the shared `SubList` trie, then clears the local dictionary. After this call, no future `ProcessMessage` call will deliver to this client's subscriptions.
|
||||
|
||||
## Dispose
|
||||
|
||||
```csharp
|
||||
public void Dispose()
|
||||
{
|
||||
_stream.Dispose();
|
||||
_socket.Dispose();
|
||||
_writeLock.Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
Disposing `_stream` closes the network stream. Disposing `_socket` closes the OS socket. Any in-flight `ReadAsync` or `WriteAsync` will fault with an `ObjectDisposedException` or `IOException`, which causes the read/write tasks to terminate. `_writeLock` is disposed last to release the `SemaphoreSlim`'s internal handle.
|
||||
|
||||
## Go Reference
|
||||
|
||||
The Go counterpart is `golang/nats-server/server/client.go`. Key differences in the .NET port:
|
||||
|
||||
- Go uses separate goroutines for `readLoop` and `writeLoop`; the .NET port uses `FillPipeAsync` and `ProcessCommandsAsync` as concurrent `Task`s sharing a `Pipe`.
|
||||
- Go uses dynamic buffer sizing (512 to 65536 bytes) in `readLoop`; the .NET port requests 4096-byte chunks from the `PipeWriter`.
|
||||
- Go uses a mutex for write serialization (`c.mu`); the .NET port uses `SemaphoreSlim(1,1)` to allow `await`-based waiting without blocking a thread.
|
||||
- The `System.IO.Pipelines` `Pipe` replaces Go's direct `net.Conn` reads. This separates the I/O pump from command parsing and avoids partial-read handling in the parser itself.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Server Overview](Overview.md)
|
||||
- [Protocol Parser](../Protocol/Parser.md)
|
||||
- [SubList Trie](../Subscriptions/SubList.md)
|
||||
- [Subscriptions Overview](../Subscriptions/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
223
Documentation/Server/Overview.md
Normal file
223
Documentation/Server/Overview.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Server Overview
|
||||
|
||||
`NatsServer` is the top-level orchestrator: it binds the TCP listener, accepts incoming connections, and routes published messages to matching subscribers. Each connected client is managed by a `NatsClient` instance; `NatsServer` coordinates them through two interfaces that decouple message routing from connection management.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Interfaces
|
||||
|
||||
`NatsServer` exposes two interfaces that `NatsClient` depends on:
|
||||
|
||||
```csharp
|
||||
public interface IMessageRouter
|
||||
{
|
||||
void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
|
||||
ReadOnlyMemory<byte> payload, NatsClient sender);
|
||||
void RemoveClient(NatsClient client);
|
||||
}
|
||||
|
||||
public interface ISubListAccess
|
||||
{
|
||||
SubList SubList { get; }
|
||||
}
|
||||
```
|
||||
|
||||
`IMessageRouter` is the surface `NatsClient` calls when a PUB command arrives. `ISubListAccess` gives `NatsClient` access to the shared `SubList` so it can insert and remove subscriptions directly — without needing a concrete reference to `NatsServer`. Both interfaces are implemented by `NatsServer`, and both are injected into `NatsClient` through the `Router` property after construction.
|
||||
|
||||
Defining them separately makes unit testing straightforward: a test can supply a stub `IMessageRouter` without standing up a real server.
|
||||
|
||||
### Fields and State
|
||||
|
||||
```csharp
|
||||
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
{
|
||||
private readonly NatsOptions _options;
|
||||
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
|
||||
private readonly SubList _subList = new();
|
||||
private readonly ServerInfo _serverInfo;
|
||||
private readonly ILogger<NatsServer> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private Socket? _listener;
|
||||
private ulong _nextClientId;
|
||||
|
||||
public SubList SubList => _subList;
|
||||
}
|
||||
```
|
||||
|
||||
`_clients` tracks every live connection. `_nextClientId` is incremented with `Interlocked.Increment` for each accepted socket, producing monotonically increasing client IDs without a lock. `_loggerFactory` is retained so per-client loggers can be created at accept time, each tagged with the client ID.
|
||||
|
||||
### Constructor
|
||||
|
||||
The constructor takes `NatsOptions` and `ILoggerFactory`. It builds a `ServerInfo` struct that is sent to every connecting client in the initial INFO message:
|
||||
|
||||
```csharp
|
||||
public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_options = options;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<NatsServer>();
|
||||
_serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
|
||||
ServerName = options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
|
||||
Version = NatsProtocol.Version,
|
||||
Host = options.Host,
|
||||
Port = options.Port,
|
||||
MaxPayload = options.MaxPayload,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The `ServerId` is derived from a GUID — taking the first 20 characters of its `"N"` format (32 hex digits, no hyphens) and uppercasing them. This matches the fixed-length alphanumeric server ID format used by the Go server.
|
||||
|
||||
## Accept Loop
|
||||
|
||||
`StartAsync` binds the socket, enables `SO_REUSEADDR` so the port can be reused immediately after a restart, and enters an async accept loop:
|
||||
|
||||
```csharp
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_listener.Bind(new IPEndPoint(
|
||||
_options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
|
||||
_options.Port));
|
||||
_listener.Listen(128);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var socket = await _listener.AcceptAsync(ct);
|
||||
var clientId = Interlocked.Increment(ref _nextClientId);
|
||||
|
||||
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
|
||||
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
|
||||
client.Router = this;
|
||||
_clients[clientId] = client;
|
||||
|
||||
_ = RunClientAsync(client, ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RunClientAsync` is fire-and-forget (`_ = ...`). The accept loop does not await it, so accepting new connections is not blocked by any single client's I/O. Each client runs concurrently on the thread pool.
|
||||
|
||||
The backlog of 128 passed to `Listen` controls the OS-level queue of unaccepted connections — matching the Go server default.
|
||||
|
||||
## Message Routing
|
||||
|
||||
`ProcessMessage` is called by `NatsClient` for every PUB or HPUB command. It is the hot path: called once per published message.
|
||||
|
||||
```csharp
|
||||
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
|
||||
ReadOnlyMemory<byte> payload, NatsClient sender)
|
||||
{
|
||||
var result = _subList.Match(subject);
|
||||
|
||||
// Deliver to plain subscribers
|
||||
foreach (var sub in result.PlainSubs)
|
||||
{
|
||||
if (sub.Client == null || sub.Client == sender && !(sender.ClientOpts?.Echo ?? true))
|
||||
continue;
|
||||
|
||||
DeliverMessage(sub, subject, replyTo, headers, payload);
|
||||
}
|
||||
|
||||
// Deliver to one member of each queue group (round-robin)
|
||||
foreach (var queueGroup in result.QueueSubs)
|
||||
{
|
||||
if (queueGroup.Length == 0) continue;
|
||||
|
||||
var idx = Math.Abs((int)Interlocked.Increment(ref sender.OutMsgs)) % queueGroup.Length;
|
||||
Interlocked.Decrement(ref sender.OutMsgs);
|
||||
|
||||
for (int attempt = 0; attempt < queueGroup.Length; attempt++)
|
||||
{
|
||||
var sub = queueGroup[(idx + attempt) % queueGroup.Length];
|
||||
if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
|
||||
{
|
||||
DeliverMessage(sub, subject, replyTo, headers, payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plain subscriber delivery
|
||||
|
||||
Each subscription in `result.PlainSubs` receives the message unless:
|
||||
|
||||
- `sub.Client` is null (the subscription was removed concurrently), or
|
||||
- the subscriber is the sender and the sender has `echo: false` in its CONNECT options.
|
||||
|
||||
The `echo` flag defaults to `true` in `ClientOptions`, so publishers receive their own messages unless they explicitly opt out.
|
||||
|
||||
### Queue group delivery
|
||||
|
||||
Queue groups provide load-balanced fan-out: exactly one member of each group receives each message. The selection uses a round-robin counter derived from `sender.OutMsgs`. An `Interlocked.Increment` picks the starting index; the `Interlocked.Decrement` immediately after undoes the side effect on the stat, since `OutMsgs` will be incremented correctly inside `SendMessageAsync` when the message is actually dispatched.
|
||||
|
||||
The loop walks from the selected index, wrapping around, until it finds an eligible member (non-null client, echo check). This handles stale subscriptions where the client has disconnected but the subscription object has not yet been cleaned up.
|
||||
|
||||
### DeliverMessage and auto-unsub
|
||||
|
||||
```csharp
|
||||
private static void DeliverMessage(Subscription sub, string subject, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
var client = sub.Client;
|
||||
if (client == null) return;
|
||||
|
||||
var count = Interlocked.Increment(ref sub.MessageCount);
|
||||
if (sub.MaxMessages > 0 && count > sub.MaxMessages)
|
||||
return;
|
||||
|
||||
_ = client.SendMessageAsync(subject, sub.Sid, replyTo, headers, payload, CancellationToken.None);
|
||||
}
|
||||
```
|
||||
|
||||
`MessageCount` is incremented atomically before the send. If it exceeds `MaxMessages` (set by an UNSUB with a message count argument), the message is silently dropped. The subscription itself is not removed here — removal happens when the client processes the count limit through `ProcessUnsub`, or when the client disconnects and `RemoveAllSubscriptions` is called.
|
||||
|
||||
`SendMessageAsync` is again fire-and-forget. Multiple deliveries to different clients happen concurrently.
|
||||
|
||||
## Client Removal
|
||||
|
||||
```csharp
|
||||
public void RemoveClient(NatsClient client)
|
||||
{
|
||||
_clients.TryRemove(client.Id, out _);
|
||||
client.RemoveAllSubscriptions(_subList);
|
||||
}
|
||||
```
|
||||
|
||||
`RemoveClient` is called either from `RunClientAsync`'s `finally` block (after a client disconnects or errors) or from `NatsClient.RunAsync`'s own `finally` block. Both paths may call it; `TryRemove` is idempotent, so double-calls are safe. After removal from `_clients`, all subscriptions belonging to that client are purged from the `SubList` trie and its internal cache.
|
||||
|
||||
## Shutdown and Dispose
|
||||
|
||||
```csharp
|
||||
public void Dispose()
|
||||
{
|
||||
_listener?.Dispose();
|
||||
foreach (var client in _clients.Values)
|
||||
client.Dispose();
|
||||
_subList.Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
Disposing the listener socket causes `AcceptAsync` to throw, which unwinds `StartAsync`. Client sockets are disposed, which closes their `NetworkStream` and causes their read loops to terminate. `SubList.Dispose` releases its `ReaderWriterLockSlim`.
|
||||
|
||||
## Go Reference
|
||||
|
||||
The Go counterpart is `golang/nats-server/server/server.go`. Key differences in the .NET port:
|
||||
|
||||
- Go uses goroutines for the accept loop and per-client read/write loops; the .NET port uses `async`/`await` with `Task`.
|
||||
- Go uses `sync/atomic` for client ID generation; the .NET port uses `Interlocked.Increment`.
|
||||
- Go passes the server to clients via the `srv` field on the client struct; the .NET port uses the `IMessageRouter` interface through the `Router` property.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Connection Handler](Client.md)
|
||||
- [SubList Trie](../Subscriptions/SubList.md)
|
||||
- [Protocol Overview](../Protocol/Overview.md)
|
||||
- [Configuration](../Configuration/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
169
Documentation/Subscriptions/Overview.md
Normal file
169
Documentation/Subscriptions/Overview.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Subscriptions Overview
|
||||
|
||||
The subscription system maps published subjects to interested subscribers. It consists of four classes: `Subscription` (the subscription model), `SubListResult` (a match result container), `SubjectMatch` (subject validation and wildcard matching), and `SubList` (the trie that routes messages to subscribers).
|
||||
|
||||
Go reference: `golang/nats-server/server/sublist.go`
|
||||
|
||||
---
|
||||
|
||||
## Subjects
|
||||
|
||||
A NATS subject is a dot-separated string of tokens: `foo.bar.baz`. Each token is a non-empty sequence of non-whitespace characters. The token separator is `.`.
|
||||
|
||||
### Wildcards
|
||||
|
||||
Two wildcard tokens are supported:
|
||||
|
||||
| Token | Name | Matches |
|
||||
|-------|------|---------|
|
||||
| `*` | Partial wildcard (Pwc) | Exactly one token at that position |
|
||||
| `>` | Full wildcard (Fwc) | One or more remaining tokens |
|
||||
|
||||
Rules:
|
||||
- `foo.*` matches `foo.bar` but not `foo.bar.baz` (only one token after `foo`).
|
||||
- `foo.>` matches `foo.bar` and `foo.bar.baz` (one or more tokens after `foo`).
|
||||
- `>` must be the last token in a subject. `foo.>.bar` is invalid.
|
||||
- Publishers cannot use wildcards — only subscribers can.
|
||||
|
||||
### Queue Groups
|
||||
|
||||
Subscribers with the same queue name form a queue group. When a message matches a queue group, exactly one member of that group receives the message. The server selects a member per published message, which distributes load across the group.
|
||||
|
||||
Queue subscriptions are independent of plain subscriptions. If a subject has both plain subscribers and queue group subscribers, all plain subscribers receive the message and one member from each queue group receives it.
|
||||
|
||||
---
|
||||
|
||||
## The `Subscription` Class
|
||||
|
||||
A `Subscription` represents one subscriber's interest in a subject.
|
||||
|
||||
```csharp
|
||||
public sealed class Subscription
|
||||
{
|
||||
public required string Subject { get; init; }
|
||||
public string? Queue { get; init; }
|
||||
public required string Sid { get; init; }
|
||||
public long MessageCount; // Interlocked
|
||||
public long MaxMessages; // 0 = unlimited
|
||||
public NatsClient? Client { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- `Subject` — the subject pattern, which may contain wildcards.
|
||||
- `Queue` — the queue group name. `null` means this is a plain subscription.
|
||||
- `Sid` — the subscription ID string assigned by the client. Used to identify the subscription in `UNSUB` commands.
|
||||
- `MessageCount` — updated with `Interlocked.Increment` on each delivered message. Compared against `MaxMessages` to implement `UNSUB maxMessages`.
|
||||
- `MaxMessages` — the auto-unsubscribe limit. `0` means unlimited.
|
||||
- `Client` — back-reference to the `NatsClient` that owns this subscription. Used during message delivery to write `MSG` frames to the connection.
|
||||
|
||||
---
|
||||
|
||||
## The `SubListResult` Class
|
||||
|
||||
`SubListResult` is the return type of `SubList.Match()`. It separates plain subscribers from queue group subscribers.
|
||||
|
||||
```csharp
|
||||
public sealed class SubListResult
|
||||
{
|
||||
public static readonly SubListResult Empty = new([], []);
|
||||
|
||||
public Subscription[] PlainSubs { get; }
|
||||
public Subscription[][] QueueSubs { get; } // outer = groups, inner = members
|
||||
|
||||
public SubListResult(Subscription[] plainSubs, Subscription[][] queueSubs)
|
||||
{
|
||||
PlainSubs = plainSubs;
|
||||
QueueSubs = queueSubs;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `PlainSubs` — all plain subscribers whose subject pattern matches the published subject.
|
||||
- `QueueSubs` — one entry per queue group. Each inner array contains all members of that group. The message router picks one member per group.
|
||||
- `Empty` — a static singleton returned when no subscriptions match. Avoids allocation on the common case of no interest.
|
||||
|
||||
---
|
||||
|
||||
## The `SubjectMatch` Class
|
||||
|
||||
`SubjectMatch` is a static utility class for subject validation and wildcard pattern matching. It defines the three fundamental constants used throughout the subscription system:
|
||||
|
||||
```csharp
|
||||
public static class SubjectMatch
|
||||
{
|
||||
public const char Pwc = '*'; // partial wildcard
|
||||
public const char Fwc = '>'; // full wildcard
|
||||
public const char Sep = '.'; // token separator
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Validation methods
|
||||
|
||||
**`IsValidSubject(string subject)`** — returns `true` if the subject is a well-formed NATS subject. Rejects:
|
||||
- Empty strings.
|
||||
- Empty tokens (consecutive separators, or leading/trailing separators).
|
||||
- Tokens containing whitespace characters.
|
||||
- Any token after `>` (full wildcard must be last).
|
||||
|
||||
**`IsLiteral(string subject)`** — returns `true` if the subject contains no wildcards. A character is treated as a wildcard only when it appears as a complete token (preceded and followed by `.` or the string boundary).
|
||||
|
||||
**`IsValidPublishSubject(string subject)`** — returns `true` if the subject is both valid and literal. Publishers must use literal subjects; this is the combined check applied before inserting a message into the routing path.
|
||||
|
||||
### Pattern matching
|
||||
|
||||
**`MatchLiteral(string literal, string pattern)`** — tests whether a literal subject matches a pattern that may contain wildcards. Used by the cache invalidation logic to determine which cached results are affected when a wildcard subscription is added or removed.
|
||||
|
||||
```csharp
|
||||
public static bool MatchLiteral(string literal, string pattern)
|
||||
{
|
||||
int li = 0, pi = 0;
|
||||
|
||||
while (pi < pattern.Length)
|
||||
{
|
||||
// Get next pattern token
|
||||
int pTokenStart = pi;
|
||||
while (pi < pattern.Length && pattern[pi] != Sep)
|
||||
pi++;
|
||||
int pTokenLen = pi - pTokenStart;
|
||||
if (pi < pattern.Length)
|
||||
pi++; // skip separator
|
||||
|
||||
// Full wildcard -- matches everything remaining
|
||||
if (pTokenLen == 1 && pattern[pTokenStart] == Fwc)
|
||||
return li < literal.Length; // must have at least one token left
|
||||
|
||||
// Get next literal token
|
||||
if (li >= literal.Length)
|
||||
return false;
|
||||
int lTokenStart = li;
|
||||
while (li < literal.Length && literal[li] != Sep)
|
||||
li++;
|
||||
int lTokenLen = li - lTokenStart;
|
||||
if (li < literal.Length)
|
||||
li++; // skip separator
|
||||
|
||||
// Partial wildcard -- matches any single token
|
||||
if (pTokenLen == 1 && pattern[pTokenStart] == Pwc)
|
||||
continue;
|
||||
|
||||
// Literal comparison
|
||||
if (pTokenLen != lTokenLen)
|
||||
return false;
|
||||
if (string.Compare(literal, lTokenStart, pattern, pTokenStart, pTokenLen, StringComparison.Ordinal) != 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
return li >= literal.Length; // both exhausted
|
||||
}
|
||||
```
|
||||
|
||||
The algorithm walks both strings token by token without allocating. `>` short-circuits immediately, returning `true` as long as at least one literal token remains. `*` skips the corresponding literal token without comparison. Literal tokens are compared with `StringComparison.Ordinal`.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SubList Trie](../Subscriptions/SubList.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
242
Documentation/Subscriptions/SubList.md
Normal file
242
Documentation/Subscriptions/SubList.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 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.
|
||||
|
||||
Go reference: `golang/nats-server/server/sublist.go`
|
||||
|
||||
---
|
||||
|
||||
## Thread Safety
|
||||
|
||||
`SubList` uses a `ReaderWriterLockSlim` (`_lock`) with the following locking discipline:
|
||||
|
||||
| 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 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.
|
||||
|
||||
---
|
||||
|
||||
## Trie Structure
|
||||
|
||||
The trie is built from two private classes, `TrieLevel` and `TrieNode`, nested inside `SubList`.
|
||||
|
||||
### `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 (>)
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
```
|
||||
|
||||
Each level in the trie represents one token position in a subject. A `TrieLevel` holds:
|
||||
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
The trie root is a `TrieLevel` (`_root`) with no parent node.
|
||||
|
||||
### `TokenEnumerator`
|
||||
|
||||
`TokenEnumerator` is a `ref struct` that splits a subject string by `.` without allocating. It operates on a `ReadOnlySpan<char>` derived from the original string.
|
||||
|
||||
```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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
```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(); }
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### `MatchLevel` traversal
|
||||
|
||||
`MatchLevel` is a recursive method that descends the trie matching tokens against the subject array. At each level, for each remaining token position:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Cache Strategy
|
||||
|
||||
The cache is a `Dictionary<string, SubListResult>` keyed by the literal published subject. All operations use `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.
|
||||
|
||||
**`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)`.
|
||||
|
||||
**`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.
|
||||
|
||||
---
|
||||
|
||||
## Disposal
|
||||
|
||||
`SubList` implements `IDisposable`. `Dispose()` releases the `ReaderWriterLockSlim`:
|
||||
|
||||
```csharp
|
||||
public void Dispose() => _lock.Dispose();
|
||||
```
|
||||
|
||||
`SubList` instances are owned by `NatsServer` and disposed during server shutdown.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Subscriptions Overview](../Subscriptions/Overview.md)
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-22 -->
|
||||
318
documentation_rules.md
Normal file
318
documentation_rules.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# Documentation Rules
|
||||
|
||||
This document defines the documentation system for the NATS .NET server project. It provides guidelines for generating, updating, and maintaining project documentation.
|
||||
|
||||
The documentation is intended for internal team reference — explaining what the system is, how it works, how to extend it, and how to debug it.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
Documentation/
|
||||
├── Instructions/ # Guidelines for LLMs (meta-documentation)
|
||||
│ └── (this file serves as the single instructions reference)
|
||||
│
|
||||
├── GettingStarted/ # Onboarding, prerequisites, first run
|
||||
├── Protocol/ # Wire protocol, parser, command types
|
||||
├── Subscriptions/ # SubList trie, subject matching, wildcards
|
||||
├── Server/ # NatsServer orchestrator, NatsClient handler
|
||||
├── Configuration/ # NatsOptions, appsettings, CLI arguments
|
||||
├── Operations/ # Deployment, monitoring, health checks, troubleshooting
|
||||
└── Plans/ # Design documents and implementation plans
|
||||
```
|
||||
|
||||
Future module folders (add as modules are ported):
|
||||
|
||||
```
|
||||
├── Authentication/ # Auth mechanisms, NKeys, JWT, accounts
|
||||
├── Clustering/ # Routes, gateways, leaf nodes
|
||||
├── JetStream/ # Streams, consumers, storage, RAFT
|
||||
├── Monitoring/ # HTTP endpoints (/varz, /connz, etc.)
|
||||
├── WebSocket/ # WebSocket transport
|
||||
└── TLS/ # TLS configuration and setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guide
|
||||
|
||||
### Tone and Voice
|
||||
|
||||
- **Technical and direct** — no marketing language. Avoid "powerful", "robust", "seamless", "blazing fast".
|
||||
- **Assume the reader is a .NET developer** — don't explain dependency injection, async/await, or LINQ basics.
|
||||
- **Explain "why" not just "what"** — document reasoning behind patterns and decisions.
|
||||
- **Use present tense** — "The parser reads..." not "The parser will read..."
|
||||
|
||||
### Formatting Rules
|
||||
|
||||
| Aspect | Convention |
|
||||
|--------|------------|
|
||||
| File names | `PascalCase.md` |
|
||||
| H1 (`#`) | Document title only, Title Case |
|
||||
| H2 (`##`) | Major sections, Title Case |
|
||||
| H3+ (`###`) | Subsections, Sentence case |
|
||||
| Code blocks | Always specify language (`csharp`, `json`, `bash`, `xml`) |
|
||||
| Code snippets | 5-25 lines typical; include class/method context |
|
||||
| Cross-references | Relative paths: `[See SubList](../Subscriptions/SubList.md)` |
|
||||
| Inline code | Backticks for code refs: `NatsServer`, `SubList.Match()`, `NatsOptions` |
|
||||
| Lists | Bullets for unordered, numbers for sequential steps |
|
||||
| Tables | For structured reference (config options, command formats) |
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Match code terminology exactly: `SubList` not "Subject List", `NatsClient` not "NATS Client Handler"
|
||||
- Use backticks for all code references: `NatsParser`, `appsettings.json`, `dotnet test`
|
||||
- Spell out acronyms on first use: "NATS Adaptive Edge Messaging (NATS)" — common acronyms that don't need expansion: API, JSON, TCP, HTTP, TLS, JWT
|
||||
|
||||
### Code Snippet Guidelines
|
||||
|
||||
**Do:**
|
||||
- Copy snippets from actual source files
|
||||
- Include enough context (class name, method signature)
|
||||
- Specify the language in code blocks
|
||||
- Show 5-25 line examples
|
||||
|
||||
```csharp
|
||||
// Good — shows class context
|
||||
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
{
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_listener.Bind(new IPEndPoint(IPAddress.Parse(_options.Host), _options.Port));
|
||||
_listener.Listen(128);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Don't:**
|
||||
- Invent example code that doesn't exist in the codebase
|
||||
- Include 100+ line dumps without explanation
|
||||
- Use pseudocode when real code is available
|
||||
- Omit the language specifier on code blocks
|
||||
|
||||
### Structure Conventions
|
||||
|
||||
Every documentation file must include:
|
||||
|
||||
1. **Title and purpose** — H1 heading with 1-2 sentence description
|
||||
2. **Key concepts** — if the topic requires background understanding
|
||||
3. **Code examples** — embedded snippets from actual codebase
|
||||
4. **Configuration** — if the component has configurable options
|
||||
5. **Related documentation** — links to related topics
|
||||
|
||||
Organize content from general to specific:
|
||||
1. Overview/introduction
|
||||
2. Key concepts
|
||||
3. Basic usage
|
||||
4. Advanced usage / internals
|
||||
5. Configuration
|
||||
6. Troubleshooting
|
||||
7. Related documentation
|
||||
|
||||
End each document with:
|
||||
```markdown
|
||||
## Related Documentation
|
||||
|
||||
- [Related Topic](../Component/Topic.md)
|
||||
```
|
||||
|
||||
### What to Avoid
|
||||
|
||||
- Don't document the obvious (e.g., "The constructor creates a new instance")
|
||||
- Don't duplicate source code comments — reference the file instead
|
||||
- Don't include temporary information (dates, version numbers, "coming soon")
|
||||
- Don't over-explain .NET basics
|
||||
|
||||
---
|
||||
|
||||
## Generating Documentation
|
||||
|
||||
### Document Types
|
||||
|
||||
Each component folder should contain these standard files:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Overview.md` | What the component does, key concepts, architecture |
|
||||
| `Development.md` | How to add/modify features, patterns to follow |
|
||||
| `Configuration.md` | All configurable options with defaults and examples |
|
||||
| `Troubleshooting.md` | Common issues, error messages, debugging steps |
|
||||
|
||||
Create additional topic-specific files as needed (e.g., `Protocol/Parser.md`, `Subscriptions/SubList.md`).
|
||||
|
||||
### Generation Process
|
||||
|
||||
1. **Identify scope** — which component folder does this belong to? (See Component Map below)
|
||||
2. **Read source code** — understand the current implementation, identify key classes/methods/patterns, note configuration options
|
||||
3. **Check existing documentation** — avoid duplication, cross-reference rather than repeat
|
||||
4. **Write documentation** — follow the style guide, use real code snippets
|
||||
5. **Verify accuracy** — confirm snippets match source, verify file paths and class names, test commands
|
||||
|
||||
### Creating New Component Folders
|
||||
|
||||
1. Create the folder under `Documentation/`
|
||||
2. Add at minimum `Overview.md`
|
||||
3. Add other standard files as content warrants
|
||||
4. Update the Component Map section below
|
||||
5. Add cross-references from related documentation
|
||||
|
||||
---
|
||||
|
||||
## Updating Documentation
|
||||
|
||||
### Update Triggers
|
||||
|
||||
| Code Change | Update These Docs |
|
||||
|-------------|-------------------|
|
||||
| New protocol command | `Protocol/` relevant file |
|
||||
| Parser modified | `Protocol/Parser.md` |
|
||||
| Subject matching changed | `Subscriptions/SubjectMatch.md` |
|
||||
| SubList trie modified | `Subscriptions/SubList.md` |
|
||||
| New subscription type | `Subscriptions/Overview.md` |
|
||||
| NatsServer changed | `Server/Overview.md` |
|
||||
| NatsClient changed | `Server/Client.md` |
|
||||
| Config option added/removed | Component's `Configuration.md` |
|
||||
| NatsOptions changed | `Configuration/Overview.md` |
|
||||
| Host startup changed | `Operations/Deployment.md` + `Configuration/` |
|
||||
| New test patterns | Corresponding component docs |
|
||||
| Auth mechanism added | `Authentication/` (create if needed) |
|
||||
| Clustering added | `Clustering/` (create if needed) |
|
||||
|
||||
### Update Process
|
||||
|
||||
1. **Identify affected documentation** — use the Component Map to determine which docs need updating
|
||||
2. **Read current documentation** — understand existing structure before making changes
|
||||
3. **Make targeted updates** — only modify sections affected by the code change; don't rewrite unaffected sections
|
||||
4. **Update code snippets** — if the code change affects documented examples, update them to match
|
||||
5. **Update cross-references** — add links to newly related docs, remove links to deleted content
|
||||
6. **Add verification comment** — at the bottom: `<!-- Last verified against codebase: YYYY-MM-DD -->`
|
||||
|
||||
### Deletion Handling
|
||||
|
||||
- When code is removed, remove corresponding doc sections
|
||||
- When code is renamed, update all references (docs, snippets, cross-reference links)
|
||||
- If an entire feature is removed, delete the doc file and update any index/overview docs
|
||||
- Search all docs for links to removed content
|
||||
|
||||
### What Not to Update
|
||||
|
||||
- Don't reformat documentation that wasn't affected by the code change
|
||||
- Don't update examples that still work correctly
|
||||
- Don't add new content unrelated to the code change
|
||||
- Don't change writing style in unaffected sections
|
||||
|
||||
---
|
||||
|
||||
## Component Map
|
||||
|
||||
### Source to Documentation Mapping
|
||||
|
||||
| Source Path | Documentation Folder |
|
||||
|-------------|---------------------|
|
||||
| `src/NATS.Server/Protocol/NatsParser.cs` | `Protocol/` |
|
||||
| `src/NATS.Server/Protocol/NatsProtocol.cs` | `Protocol/` |
|
||||
| `src/NATS.Server/Subscriptions/SubList.cs` | `Subscriptions/` |
|
||||
| `src/NATS.Server/Subscriptions/SubjectMatch.cs` | `Subscriptions/` |
|
||||
| `src/NATS.Server/Subscriptions/Subscription.cs` | `Subscriptions/` |
|
||||
| `src/NATS.Server/Subscriptions/SubListResult.cs` | `Subscriptions/` |
|
||||
| `src/NATS.Server/NatsServer.cs` | `Server/` |
|
||||
| `src/NATS.Server/NatsClient.cs` | `Server/` |
|
||||
| `src/NATS.Server/NatsOptions.cs` | `Configuration/` |
|
||||
| `src/NATS.Server.Host/Program.cs` | `Operations/` and `Configuration/` |
|
||||
| `tests/NATS.Server.Tests/` | Document in corresponding component |
|
||||
| `golang/nats-server/server/` | Reference material (not documented separately) |
|
||||
|
||||
### Component Details
|
||||
|
||||
#### Protocol/
|
||||
|
||||
Documents the wire protocol and parser.
|
||||
|
||||
**Source paths:**
|
||||
- `src/NATS.Server/Protocol/NatsParser.cs` — state machine parser
|
||||
- `src/NATS.Server/Protocol/NatsProtocol.cs` — constants, ServerInfo, ClientOptions
|
||||
|
||||
**Typical files:**
|
||||
- `Overview.md` — NATS protocol format, command types, wire format
|
||||
- `Parser.md` — Parser implementation, `TryParse` flow, state machine
|
||||
- `Commands.md` — Individual command formats (PUB, SUB, UNSUB, MSG, etc.)
|
||||
|
||||
#### Subscriptions/
|
||||
|
||||
Documents subject matching and the subscription trie.
|
||||
|
||||
**Source paths:**
|
||||
- `src/NATS.Server/Subscriptions/SubList.cs` — trie + cache
|
||||
- `src/NATS.Server/Subscriptions/SubjectMatch.cs` — validation and wildcard matching
|
||||
- `src/NATS.Server/Subscriptions/Subscription.cs` — subscription model
|
||||
- `src/NATS.Server/Subscriptions/SubListResult.cs` — match result container
|
||||
|
||||
**Typical files:**
|
||||
- `Overview.md` — Subject namespace, wildcard rules, queue groups
|
||||
- `SubList.md` — Trie internals, cache invalidation, thread safety
|
||||
- `SubjectMatch.md` — Validation rules, wildcard matching algorithm
|
||||
|
||||
#### Server/
|
||||
|
||||
Documents the server orchestrator and client connection handler.
|
||||
|
||||
**Source paths:**
|
||||
- `src/NATS.Server/NatsServer.cs` — accept loop, message routing
|
||||
- `src/NATS.Server/NatsClient.cs` — per-connection read/write, subscription tracking
|
||||
|
||||
**Typical files:**
|
||||
- `Overview.md` — Server architecture, connection lifecycle, message flow
|
||||
- `Client.md` — Client connection handling, command dispatch, write serialization
|
||||
- `MessageRouting.md` — How messages flow from PUB to subscribers
|
||||
|
||||
#### Configuration/
|
||||
|
||||
Documents server configuration options.
|
||||
|
||||
**Source paths:**
|
||||
- `src/NATS.Server/NatsOptions.cs` — configuration model
|
||||
- `src/NATS.Server.Host/Program.cs` — CLI argument parsing, Serilog setup
|
||||
|
||||
**Typical files:**
|
||||
- `Overview.md` — All options with defaults and descriptions
|
||||
- `Logging.md` — Serilog configuration, log levels, LogContext usage
|
||||
|
||||
#### Operations/
|
||||
|
||||
Documents deployment and operational concerns.
|
||||
|
||||
**Source paths:**
|
||||
- `src/NATS.Server.Host/` — host application
|
||||
|
||||
**Typical files:**
|
||||
- `Overview.md` — Running the server, CLI arguments
|
||||
- `Deployment.md` — Deployment procedures
|
||||
- `Troubleshooting.md` — Common issues and debugging
|
||||
|
||||
#### GettingStarted/
|
||||
|
||||
Documents onboarding and project overview.
|
||||
|
||||
**Typical files:**
|
||||
- `Setup.md` — Prerequisites, building, running
|
||||
- `Architecture.md` — System overview, Go reference mapping
|
||||
- `Development.md` — Development workflow, testing, contributing
|
||||
|
||||
### Ambiguous Cases
|
||||
|
||||
| Code Type | Document In |
|
||||
|-----------|-------------|
|
||||
| Logging setup | `Configuration/Logging.md` |
|
||||
| Integration tests | `Operations/Testing.md` or corresponding component |
|
||||
| Shared interfaces (`IMessageRouter`, `ISubListAccess`) | `Server/Overview.md` |
|
||||
| Go reference code | Don't document separately; reference in `.NET` component docs |
|
||||
|
||||
### Adding New Components
|
||||
|
||||
When a new module is ported (Authentication, Clustering, JetStream, etc.):
|
||||
|
||||
1. Create a new folder under `Documentation/`
|
||||
2. Add at minimum `Overview.md`
|
||||
3. Add this mapping table entry
|
||||
4. Update CLAUDE.md documentation index if it has one
|
||||
5. Cross-reference from related component docs
|
||||
@@ -8,4 +8,9 @@
|
||||
<ProjectReference Include="..\NATS.Server\NATS.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// src/NATS.Server.Host/Program.cs
|
||||
using NATS.Server;
|
||||
using Serilog;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
var options = new NatsOptions();
|
||||
|
||||
@@ -20,7 +26,8 @@ for (int i = 0; i < args.Length; i++)
|
||||
}
|
||||
}
|
||||
|
||||
var server = new NatsServer(options);
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
var server = new NatsServer(options, loggerFactory);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
@@ -29,12 +36,12 @@ Console.CancelKeyPress += (_, e) =>
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
Console.WriteLine($"[NATS] Listening on {options.Host}:{options.Port}");
|
||||
|
||||
try
|
||||
{
|
||||
await server.StartAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
|
||||
Console.WriteLine("[NATS] Server stopped.");
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO.Pipelines;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
@@ -29,6 +30,7 @@ public sealed class NatsClient : IDisposable
|
||||
private readonly NatsParser _parser;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly Dictionary<string, Subscription> _subs = new();
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ulong Id { get; }
|
||||
public ClientOptions? ClientOpts { get; private set; }
|
||||
@@ -43,13 +45,14 @@ public sealed class NatsClient : IDisposable
|
||||
|
||||
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
|
||||
|
||||
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo)
|
||||
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo, ILogger logger)
|
||||
{
|
||||
Id = id;
|
||||
_socket = socket;
|
||||
_stream = new NetworkStream(socket, ownsSocket: false);
|
||||
_options = options;
|
||||
_serverInfo = serverInfo;
|
||||
_logger = logger;
|
||||
_parser = new NatsParser(options.MaxPayload);
|
||||
}
|
||||
|
||||
@@ -68,7 +71,10 @@ public sealed class NatsClient : IDisposable
|
||||
await Task.WhenAny(fillTask, processTask);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* connection error -- clean up */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Router?.RemoveClient(this);
|
||||
@@ -160,6 +166,7 @@ public sealed class NatsClient : IDisposable
|
||||
ClientOpts = JsonSerializer.Deserialize<ClientOptions>(cmd.Payload.Span)
|
||||
?? new ClientOptions();
|
||||
ConnectReceived = true;
|
||||
_logger.LogDebug("CONNECT received from client {ClientId}, name={ClientName}", Id, ClientOpts?.Name);
|
||||
}
|
||||
|
||||
private void ProcessSub(ParsedCommand cmd)
|
||||
@@ -174,12 +181,16 @@ public sealed class NatsClient : IDisposable
|
||||
_subs[cmd.Sid!] = sub;
|
||||
sub.Client = this;
|
||||
|
||||
_logger.LogDebug("SUB {Subject} {Sid} from client {ClientId}", cmd.Subject, cmd.Sid, Id);
|
||||
|
||||
if (Router is ISubListAccess sl)
|
||||
sl.SubList.Insert(sub);
|
||||
}
|
||||
|
||||
private void ProcessUnsub(ParsedCommand cmd)
|
||||
{
|
||||
_logger.LogDebug("UNSUB {Sid} from client {ClientId}", cmd.Sid, Id);
|
||||
|
||||
if (!_subs.TryGetValue(cmd.Sid!, out var sub))
|
||||
return;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
@@ -12,14 +13,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
|
||||
private readonly SubList _subList = new();
|
||||
private readonly ServerInfo _serverInfo;
|
||||
private readonly ILogger<NatsServer> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private Socket? _listener;
|
||||
private ulong _nextClientId;
|
||||
|
||||
public SubList SubList => _subList;
|
||||
|
||||
public NatsServer(NatsOptions options)
|
||||
public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_options = options;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<NatsServer>();
|
||||
_serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
|
||||
@@ -40,6 +45,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_options.Port));
|
||||
_listener.Listen(128);
|
||||
|
||||
_logger.LogInformation("Listening on {Host}:{Port}", _options.Host, _options.Port);
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -47,7 +54,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
var socket = await _listener.AcceptAsync(ct);
|
||||
var clientId = Interlocked.Increment(ref _nextClientId);
|
||||
|
||||
var client = new NatsClient(clientId, socket, _options, _serverInfo);
|
||||
_logger.LogDebug("Client {ClientId} connected from {RemoteEndpoint}", clientId, socket.RemoteEndPoint);
|
||||
|
||||
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
|
||||
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
|
||||
client.Router = this;
|
||||
_clients[clientId] = client;
|
||||
|
||||
@@ -69,6 +79,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} disconnected", client.Id);
|
||||
RemoveClient(client);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +138,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
public void RemoveClient(NatsClient client)
|
||||
{
|
||||
_clients.TryRemove(client.Id, out _);
|
||||
_logger.LogDebug("Removed client {ClientId}", client.Id);
|
||||
client.RemoveAllSubscriptions(_subList);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
@@ -37,7 +39,7 @@ public class ClientTests : IAsyncDisposable
|
||||
Port = 4222,
|
||||
};
|
||||
|
||||
_natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo);
|
||||
_natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo, NullLogger.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -57,9 +59,9 @@ public class ClientTests : IAsyncDisposable
|
||||
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
Assert.StartsWith("INFO ", response);
|
||||
Assert.Contains("server_id", response);
|
||||
Assert.Contains("\r\n", response);
|
||||
response.ShouldStartWith("INFO ");
|
||||
response.ShouldContain("server_id");
|
||||
response.ShouldContain("\r\n");
|
||||
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
@@ -80,7 +82,7 @@ public class ClientTests : IAsyncDisposable
|
||||
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
Assert.Contains("PONG\r\n", response);
|
||||
response.ShouldContain("PONG\r\n");
|
||||
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server;
|
||||
|
||||
@@ -16,7 +17,7 @@ public class IntegrationTests : IAsyncDisposable
|
||||
public IntegrationTests()
|
||||
{
|
||||
_port = GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions { Port = _port });
|
||||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||||
_serverTask = _server.StartAsync(_cts.Token);
|
||||
Thread.Sleep(200); // Let server start
|
||||
}
|
||||
@@ -65,7 +66,7 @@ public class IntegrationTests : IAsyncDisposable
|
||||
await pub.PublishAsync("test.subject", "Hello NATS!");
|
||||
|
||||
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Hello NATS!", result);
|
||||
result.ShouldBe("Hello NATS!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -92,7 +93,7 @@ public class IntegrationTests : IAsyncDisposable
|
||||
await pub.PublishAsync("test.hello", "data");
|
||||
|
||||
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("test.hello", result);
|
||||
result.ShouldBe("test.hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -119,7 +120,7 @@ public class IntegrationTests : IAsyncDisposable
|
||||
await pub.PublishAsync("test.foo.bar.baz", "data");
|
||||
|
||||
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("test.foo.bar.baz", result);
|
||||
result.ShouldBe("test.foo.bar.baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -163,8 +164,8 @@ public class IntegrationTests : IAsyncDisposable
|
||||
await pub.PublishAsync("fanout", "hello");
|
||||
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(1, count1);
|
||||
Assert.Equal(1, count2);
|
||||
count1.ShouldBe(1);
|
||||
count2.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NATS.Client.Core" Version="2.7.2" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<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>
|
||||
|
||||
@@ -40,117 +40,117 @@ public class ParserTests
|
||||
public async Task Parse_PING()
|
||||
{
|
||||
var cmds = await ParseAsync("PING\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Ping, cmds[0].Type);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Ping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PONG()
|
||||
{
|
||||
var cmds = await ParseAsync("PONG\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Pong, cmds[0].Type);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_CONNECT()
|
||||
{
|
||||
var cmds = await ParseAsync("CONNECT {\"verbose\":false,\"echo\":true}\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Connect, cmds[0].Type);
|
||||
Assert.Contains("verbose", Encoding.ASCII.GetString(cmds[0].Payload.ToArray()));
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Connect);
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldContain("verbose");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_SUB_without_queue()
|
||||
{
|
||||
var cmds = await ParseAsync("SUB foo 1\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Sub, cmds[0].Type);
|
||||
Assert.Equal("foo", cmds[0].Subject);
|
||||
Assert.Null(cmds[0].Queue);
|
||||
Assert.Equal("1", cmds[0].Sid);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Sub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].Queue.ShouldBeNull();
|
||||
cmds[0].Sid.ShouldBe("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_SUB_with_queue()
|
||||
{
|
||||
var cmds = await ParseAsync("SUB foo workers 1\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Sub, cmds[0].Type);
|
||||
Assert.Equal("foo", cmds[0].Subject);
|
||||
Assert.Equal("workers", cmds[0].Queue);
|
||||
Assert.Equal("1", cmds[0].Sid);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Sub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].Queue.ShouldBe("workers");
|
||||
cmds[0].Sid.ShouldBe("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_UNSUB()
|
||||
{
|
||||
var cmds = await ParseAsync("UNSUB 1\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Unsub, cmds[0].Type);
|
||||
Assert.Equal("1", cmds[0].Sid);
|
||||
Assert.Equal(-1, cmds[0].MaxMessages);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Unsub);
|
||||
cmds[0].Sid.ShouldBe("1");
|
||||
cmds[0].MaxMessages.ShouldBe(-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_UNSUB_with_max()
|
||||
{
|
||||
var cmds = await ParseAsync("UNSUB 1 5\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Unsub, cmds[0].Type);
|
||||
Assert.Equal("1", cmds[0].Sid);
|
||||
Assert.Equal(5, cmds[0].MaxMessages);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Unsub);
|
||||
cmds[0].Sid.ShouldBe("1");
|
||||
cmds[0].MaxMessages.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PUB_with_payload()
|
||||
{
|
||||
var cmds = await ParseAsync("PUB foo 5\r\nHello\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Pub, cmds[0].Type);
|
||||
Assert.Equal("foo", cmds[0].Subject);
|
||||
Assert.Null(cmds[0].ReplyTo);
|
||||
Assert.Equal("Hello", Encoding.ASCII.GetString(cmds[0].Payload.ToArray()));
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].ReplyTo.ShouldBeNull();
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PUB_with_reply()
|
||||
{
|
||||
var cmds = await ParseAsync("PUB foo reply 5\r\nHello\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Pub, cmds[0].Type);
|
||||
Assert.Equal("foo", cmds[0].Subject);
|
||||
Assert.Equal("reply", cmds[0].ReplyTo);
|
||||
Assert.Equal("Hello", Encoding.ASCII.GetString(cmds[0].Payload.ToArray()));
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].ReplyTo.ShouldBe("reply");
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_multiple_commands()
|
||||
{
|
||||
var cmds = await ParseAsync("PING\r\nPONG\r\nSUB foo 1\r\n");
|
||||
Assert.Equal(3, cmds.Count);
|
||||
Assert.Equal(CommandType.Ping, cmds[0].Type);
|
||||
Assert.Equal(CommandType.Pong, cmds[1].Type);
|
||||
Assert.Equal(CommandType.Sub, cmds[2].Type);
|
||||
cmds.Count.ShouldBe(3);
|
||||
cmds[0].Type.ShouldBe(CommandType.Ping);
|
||||
cmds[1].Type.ShouldBe(CommandType.Pong);
|
||||
cmds[2].Type.ShouldBe(CommandType.Sub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PUB_zero_payload()
|
||||
{
|
||||
var cmds = await ParseAsync("PUB foo 0\r\n\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Pub, cmds[0].Type);
|
||||
Assert.Empty(cmds[0].Payload.ToArray());
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Payload.ToArray().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_case_insensitive()
|
||||
{
|
||||
var cmds = await ParseAsync("ping\r\npub FOO 3\r\nabc\r\n");
|
||||
Assert.Equal(2, cmds.Count);
|
||||
Assert.Equal(CommandType.Ping, cmds[0].Type);
|
||||
Assert.Equal(CommandType.Pub, cmds[1].Type);
|
||||
cmds.Count.ShouldBe(2);
|
||||
cmds[0].Type.ShouldBe(CommandType.Ping);
|
||||
cmds[1].Type.ShouldBe(CommandType.Pub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -161,17 +161,17 @@ public class ParserTests
|
||||
var payload = "Hello";
|
||||
var total = header.Length + payload.Length;
|
||||
var cmds = await ParseAsync($"HPUB foo {header.Length} {total}\r\n{header}{payload}\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.HPub, cmds[0].Type);
|
||||
Assert.Equal("foo", cmds[0].Subject);
|
||||
Assert.Equal(header.Length, cmds[0].HeaderSize);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.HPub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].HeaderSize.ShouldBe(header.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_INFO()
|
||||
{
|
||||
var cmds = await ParseAsync("INFO {\"server_id\":\"test\"}\r\n");
|
||||
Assert.Single(cmds);
|
||||
Assert.Equal(CommandType.Info, cmds[0].Type);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
@@ -15,7 +16,7 @@ public class ServerTests : IAsyncDisposable
|
||||
{
|
||||
// Use random port
|
||||
_port = GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions { Port = _port });
|
||||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -54,7 +55,7 @@ public class ServerTests : IAsyncDisposable
|
||||
using var client = await ConnectClientAsync();
|
||||
var response = await ReadLineAsync(client);
|
||||
|
||||
Assert.StartsWith("INFO ", response);
|
||||
response.ShouldStartWith("INFO ");
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
@@ -84,7 +85,7 @@ public class ServerTests : IAsyncDisposable
|
||||
var n = await sub.ReceiveAsync(buf, SocketFlags.None);
|
||||
var msg = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
Assert.Contains("MSG foo 1 5\r\nHello\r\n", msg);
|
||||
msg.ShouldContain("MSG foo 1 5\r\nHello\r\n");
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@ public class ServerTests : IAsyncDisposable
|
||||
var n = await sub.ReceiveAsync(buf, SocketFlags.None);
|
||||
var msg = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
Assert.Contains("MSG foo.bar 1 5\r\n", msg);
|
||||
msg.ShouldContain("MSG foo.bar 1 5\r\n");
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ public class SubListTests
|
||||
sl.Insert(sub);
|
||||
|
||||
var r = sl.Match("foo.bar");
|
||||
Assert.Single(r.PlainSubs);
|
||||
Assert.Same(sub, r.PlainSubs[0]);
|
||||
Assert.Empty(r.QueueSubs);
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.PlainSubs[0].ShouldBeSameAs(sub);
|
||||
r.QueueSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -27,7 +27,7 @@ public class SubListTests
|
||||
sl.Insert(MakeSub("foo.bar"));
|
||||
|
||||
var r = sl.Match("foo.baz");
|
||||
Assert.Empty(r.PlainSubs);
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -37,9 +37,9 @@ public class SubListTests
|
||||
var sub = MakeSub("foo.*");
|
||||
sl.Insert(sub);
|
||||
|
||||
Assert.Single(sl.Match("foo.bar").PlainSubs);
|
||||
Assert.Single(sl.Match("foo.baz").PlainSubs);
|
||||
Assert.Empty(sl.Match("foo.bar.baz").PlainSubs);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar.baz").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -49,9 +49,9 @@ public class SubListTests
|
||||
var sub = MakeSub("foo.>");
|
||||
sl.Insert(sub);
|
||||
|
||||
Assert.Single(sl.Match("foo.bar").PlainSubs);
|
||||
Assert.Single(sl.Match("foo.bar.baz").PlainSubs);
|
||||
Assert.Empty(sl.Match("foo").PlainSubs);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -60,9 +60,9 @@ public class SubListTests
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub(">"));
|
||||
|
||||
Assert.Single(sl.Match("foo").PlainSubs);
|
||||
Assert.Single(sl.Match("foo.bar").PlainSubs);
|
||||
Assert.Single(sl.Match("foo.bar.baz").PlainSubs);
|
||||
sl.Match("foo").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +75,7 @@ public class SubListTests
|
||||
sl.Insert(MakeSub(">", sid: "4"));
|
||||
|
||||
var r = sl.Match("foo.bar");
|
||||
Assert.Equal(4, r.PlainSubs.Length);
|
||||
r.PlainSubs.Length.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,10 +84,10 @@ public class SubListTests
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.bar");
|
||||
sl.Insert(sub);
|
||||
Assert.Single(sl.Match("foo.bar").PlainSubs);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
|
||||
sl.Remove(sub);
|
||||
Assert.Empty(sl.Match("foo.bar").PlainSubs);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,19 +99,19 @@ public class SubListTests
|
||||
sl.Insert(MakeSub("foo.bar", queue: "loggers", sid: "3"));
|
||||
|
||||
var r = sl.Match("foo.bar");
|
||||
Assert.Empty(r.PlainSubs);
|
||||
Assert.Equal(2, r.QueueSubs.Length); // 2 queue groups
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
r.QueueSubs.Length.ShouldBe(2); // 2 queue groups
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_tracks_subscriptions()
|
||||
{
|
||||
var sl = new SubList();
|
||||
Assert.Equal(0u, sl.Count);
|
||||
sl.Count.ShouldBe(0u);
|
||||
|
||||
sl.Insert(MakeSub("foo", sid: "1"));
|
||||
sl.Insert(MakeSub("bar", sid: "2"));
|
||||
Assert.Equal(2u, sl.Count);
|
||||
sl.Count.ShouldBe(2u);
|
||||
|
||||
sl.Remove(MakeSub("foo", sid: "1"));
|
||||
// Remove by reference won't work — we need the same instance
|
||||
@@ -123,9 +123,9 @@ public class SubListTests
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo");
|
||||
sl.Insert(sub);
|
||||
Assert.Equal(1u, sl.Count);
|
||||
sl.Count.ShouldBe(1u);
|
||||
sl.Remove(sub);
|
||||
Assert.Equal(0u, sl.Count);
|
||||
sl.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -136,13 +136,13 @@ public class SubListTests
|
||||
|
||||
// Prime the cache
|
||||
var r1 = sl.Match("foo.bar");
|
||||
Assert.Single(r1.PlainSubs);
|
||||
r1.PlainSubs.ShouldHaveSingleItem();
|
||||
|
||||
// Insert a wildcard that matches — cache should be invalidated
|
||||
sl.Insert(MakeSub("foo.*", sid: "2"));
|
||||
|
||||
var r2 = sl.Match("foo.bar");
|
||||
Assert.Equal(2, r2.PlainSubs.Length);
|
||||
r2.PlainSubs.Length.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -154,6 +154,6 @@ public class SubListTests
|
||||
sl.Insert(MakeSub("foo.bar.*", sid: "3"));
|
||||
|
||||
var r = sl.Match("foo.bar.baz");
|
||||
Assert.Equal(3, r.PlainSubs.Length);
|
||||
r.PlainSubs.Length.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class SubjectMatchTests
|
||||
[InlineData("foo\tbar", false)] // no tabs
|
||||
public void IsValidSubject(string subject, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SubjectMatch.IsValidSubject(subject));
|
||||
SubjectMatch.IsValidSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -35,7 +35,7 @@ public class SubjectMatchTests
|
||||
[InlineData("*", false)]
|
||||
public void IsValidPublishSubject(string subject, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SubjectMatch.IsValidPublishSubject(subject));
|
||||
SubjectMatch.IsValidPublishSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -52,6 +52,6 @@ public class SubjectMatchTests
|
||||
[InlineData("foo.bar", "foo.bar.>", false)]
|
||||
public void MatchLiteral(string literal, string pattern, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SubjectMatch.MatchLiteral(literal, pattern));
|
||||
SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user