Design covers the minimal NATS server port: pub/sub with wildcards and queue groups over System.IO.Pipelines, targeting .NET 10.
6.1 KiB
NATS .NET Base Server Design
Date: 2026-02-22 Scope: Minimal single-node NATS server — pub/sub, queue groups, wildcards. No auth, clustering, JetStream, or monitoring. Target: .NET 10, C# Approach: Bottom-up (Sublist → Parser → Client → Server)
Decisions
- Framework: .NET 10
- I/O: System.IO.Pipelines for zero-copy protocol parsing
- Structure: Library (NATS.Server) + Host (NATS.Server.Host) + Tests (NATS.Server.Tests)
- Test framework: xUnit
- Queue group selection: Round-robin with atomic counter
Solution Layout
natsdotnet/
├── NatsDotNet.sln
├── src/
│ ├── NATS.Server/ # Core library
│ │ ├── NatsServer.cs # Server orchestrator
│ │ ├── NatsClient.cs # Client connection
│ │ ├── NatsOptions.cs # Configuration
│ │ ├── Protocol/
│ │ │ ├── NatsProtocol.cs # Constants, op codes
│ │ │ └── NatsParser.cs # Pipeline-based parser
│ │ └── Subscriptions/
│ │ ├── Subscription.cs
│ │ ├── SubjectMatch.cs
│ │ └── SubList.cs # Trie + cache
│ └── NATS.Server.Host/ # Console app
│ └── Program.cs
└── tests/
└── NATS.Server.Tests/
Component Designs
1. SubList (subject matching trie)
Reference: golang/nats-server/server/sublist.go
Structures:
SubList— root trie + cache (Dictionary<string, SubListResult>) +ReaderWriterLockSlim+ atomicgenIdTrieLevel—Dictionary<string, TrieNode>for literal tokens, nullablepwc(*) andfwc(>) pointersTrieNode— optionalnext: TrieLevel,HashSet<Subscription>for plain subs,Dictionary<string, HashSet<Subscription>>for queue subsSubscription— client ref, subject, queue, sid, atomic message count, max messagesSubListResult—Subscription[]plain subs,Subscription[][]queue subs (grouped)
Operations:
Insert(Subscription)— walk trie by.-split tokens, create nodes, add sub, increment genId (clears cache)Remove(Subscription)— reverse, prune empty nodes, increment genIdMatch(ReadOnlySpan<byte>)— cache check (convert to string key on miss), walk trie collecting matches from literal +*+>nodes- Thread safety: read lock for Match, write lock for Insert/Remove
2. Protocol Parser
Reference: golang/nats-server/server/parser.go
Operates on PipeReader. Scans for \r\n to delimit control lines. Identifies command by first bytes. For PUB/HPUB, reads control line then exactly size bytes of payload + \r\n.
Commands (base server):
| Command | Direction | Control line 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[hdrs+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[hdrs+payload]\r\n |
| PING/PONG | Both | PING\r\n / PONG\r\n |
| +OK / -ERR | S→C | +OK\r\n / -ERR 'msg'\r\n |
Design choices:
- Parser returns parsed command structs, does not dispatch
- No string allocation on hot path — subject matching uses
Span<byte> - Max control line: 4096 bytes, max payload: 1MB default (configurable)
3. NatsClient (connection handler)
Reference: golang/nats-server/server/client.go
Fields: unique id, Socket, IDuplexPipe, back-ref to server, subs dict (sid→Subscription), ClientOptions (from CONNECT json), stats (Interlocked counters), SemaphoreSlim for write serialization.
Lifecycle:
- Server accepts socket, creates NatsClient
RunAsync(CancellationToken): send INFO → read loop (parse + dispatch commands)- Dispatch: CONNECT→validate, SUB→insert into SubList, UNSUB→remove, PUB→
server.ProcessMessage(), PING→PONG - On disconnect: remove all subs, remove from server clients dict
Writing: SendMessageAsync() formats MSG/HMSG, acquires write lock, writes to PipeWriter, flushes.
4. NatsServer (orchestrator)
Reference: golang/nats-server/server/server.go
Fields: NatsOptions, TCP listener Socket, ConcurrentDictionary<ulong, NatsClient>, SubList, atomic next client ID, ServerInfo struct, CancellationTokenSource.
Core methods:
StartAsync()— bind, accept loopProcessMessage(subject, reply, headers, payload, sender)— SubList.Match → deliver to plain subs + pick one per queue group (round-robin)RemoveClient(client)— cleanup subs and client dictShutdown()— cancel, close listener, drain
5. NatsOptions
Reference: golang/nats-server/server/opts.go
Minimal for base: Host, Port (default 4222), ServerName, MaxPayload (1MB), MaxControlLine (4096), MaxConnections, PingInterval, MaxPingsOut.
Testing Strategy
Unit tests:
- SubList: insert/remove/match with literals,
*,>, queue groups, cache invalidation. Port Go test cases. - Parser: each command type, malformed input, partial reads, boundary sizes.
- Client protocol: CONNECT handshake, SUB/UNSUB lifecycle, PUB delivery, PING/PONG via loopback sockets.
Integration tests (using NATS.Client.Core NuGet):
- Basic pub/sub, wildcard matching, queue group distribution, fan-out, UNSUB + auto-unsub, PING/PONG.
Module Boundaries (future)
These are explicitly excluded from this design and will be added as separate modules later:
| Module | Key files in Go reference |
|---|---|
| Authentication | auth.go, auth_callout.go, nkey.go, jwt.go |
| Monitoring HTTP | monitor.go |
| Clustering/Routes | route.go |
| Gateways | gateway.go |
| Leaf Nodes | leafnode.go |
| JetStream | jetstream.go, stream.go, consumer.go, filestore.go, memstore.go, raft.go |
| WebSocket | websocket.go |
| MQTT | mqtt.go |
| TLS | TLS config in opts.go, various TLS setup in server.go |