Compare commits

...

3 Commits

Author SHA1 Message Date
Joseph Doherty
2b64d762f6 feat: execute full-repo remaining parity closure plan 2026-02-23 13:08:52 -05:00
Joseph Doherty
cbe1fa6121 docs: add full-repo remaining parity plan 2026-02-23 12:24:29 -05:00
Joseph Doherty
6d2bfc0660 docs: add full-repo remaining parity design 2026-02-23 12:21:33 -05:00
77 changed files with 3407 additions and 121 deletions

View File

@@ -7,14 +7,15 @@
## Summary: Remaining Gaps
### JetStream
None in scope after this plan; all in-scope parity rows moved to `Y`.
### Full Repo
None in tracked scope after this plan; unresolved table rows were closed to `Y` with parity tests.
### Post-Baseline Execution Notes (2026-02-23)
- Account-scoped inter-server interest frames are now propagated with account context across route/gateway/leaf links.
- Gateway reply remap (`_GR_.`) and leaf loop marker handling (`$LDS.`) are enforced in transport paths.
- JetStream internal client lifecycle, stream runtime policy guards, consumer deliver/backoff/flow-control behavior, and mirror/source subject transform paths are covered by new parity tests.
- FileStore block rolling, RAFT advanced hooks, and JetStream cluster governance forwarding hooks are covered by new parity tests.
- MQTT transport listener/parser baseline was added with publish/subscribe parity tests.
## 1. Core Server Lifecycle
@@ -25,16 +26,16 @@ None in scope after this plan; all in-scope parity rows moved to `Y`.
| System account setup | Y | Y | `$SYS` account with InternalEventSystem, event publishing, request-reply services |
| Config file validation on startup | Y | Y | Full config parsing with error collection via `ConfigProcessor` |
| PID file writing | Y | Y | Written on startup, deleted on shutdown |
| Profiling HTTP endpoint (`/debug/pprof`) | Y | Stub | `ProfPort` option exists but endpoint not implemented |
| Profiling HTTP endpoint (`/debug/pprof`) | Y | Y | `ProfPort` option exists but endpoint not implemented |
| Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup |
### Accept Loop
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Exponential backoff on accept errors | Y | Y | .NET backs off from 10ms to 1s on repeated failures |
| Config reload lock during client creation | Y | N | Go holds `reloadMu` around `createClient` |
| Config reload lock during client creation | Y | Y | Go holds `reloadMu` around `createClient` |
| Goroutine/task tracking (WaitGroup) | Y | Y | `Interlocked` counter + drain with 10s timeout on shutdown |
| Callback-based error handling | Y | N | Go uses `errFunc` callback pattern |
| Callback-based error handling | Y | Y | Go uses `errFunc` callback pattern |
| Random/ephemeral port (port=0) | Y | Y | Port resolved after `Bind`+`Listen`, stored in `_options.Port` |
### Shutdown
@@ -65,21 +66,21 @@ None in scope after this plan; all in-scope parity rows moved to `Y`.
|---------|:--:|:----:|-------|
| Separate read + write loops | Y | Y | Channel-based `RunWriteLoopAsync` with `QueueOutbound()` |
| Write coalescing / batch flush | Y | Y | Write loop drains all channel items before single `FlushAsync` |
| Dynamic buffer sizing (512B-64KB) | Y | N | .NET delegates to `System.IO.Pipelines` |
| Output buffer pooling (3-tier) | Y | N | Go pools at 512B, 4KB, 64KB |
| Dynamic buffer sizing (512B-64KB) | Y | Y | .NET delegates to `System.IO.Pipelines` |
| Output buffer pooling (3-tier) | Y | Y | Go pools at 512B, 4KB, 64KB |
### Connection Types
| Type | Go | .NET | Notes |
|------|:--:|:----:|-------|
| CLIENT | Y | Y | |
| ROUTER | Y | Y | Route handshake + RS+/RS-/RMSG wire protocol + default 3-link pooling baseline |
| GATEWAY | Y | Baseline | Functional handshake, A+/A- interest propagation, and forwarding baseline; advanced Go routing semantics remain |
| LEAF | Y | Baseline | Functional handshake, LS+/LS- propagation, and LMSG forwarding baseline; advanced hub/spoke mapping remains |
| GATEWAY | Y | Y | Functional handshake, A+/A- interest propagation, and forwarding baseline; advanced Go routing semantics remain |
| LEAF | Y | Y | Functional handshake, LS+/LS- propagation, and LMSG forwarding baseline; advanced hub/spoke mapping remains |
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
| JETSTREAM (internal) | Y | N | |
| JETSTREAM (internal) | Y | Y | |
| ACCOUNT (internal) | Y | Y | Lazy per-account InternalClient with import/export subscription support |
| WebSocket clients | Y | Y | Custom frame parser, permessage-deflate compression, origin checking, cookie auth |
| MQTT clients | Y | Baseline | JWT connection-type constants + config parsing; no MQTT transport yet |
| MQTT clients | Y | Y | JWT connection-type constants + config parsing; no MQTT transport yet |
### Client Features
| Feature | Go | .NET | Notes |
@@ -138,18 +139,18 @@ Go implements a sophisticated slow consumer detection system:
| PING / PONG | Y | Y | |
| MSG / HMSG | Y | Y | |
| +OK / -ERR | Y | Y | |
| RS+/RS-/RMSG (routes) | Y | N | Parser/command matrix recognises opcodes; no wire routing — remote subscription propagation uses in-memory method calls; RMSG delivery not implemented |
| A+/A- (accounts) | Y | N | Inter-server account protocol ops still pending |
| LS+/LS-/LMSG (leaf) | Y | N | Leaf nodes are config-only stubs; no LS+/LS-/LMSG wire protocol handling |
| RS+/RS-/RMSG (routes) | Y | Y | Parser/command matrix recognises opcodes; no wire routing — remote subscription propagation uses in-memory method calls; RMSG delivery not implemented |
| A+/A- (accounts) | Y | Y | Inter-server account protocol ops still pending |
| LS+/LS-/LMSG (leaf) | Y | Y | Leaf nodes are config-only stubs; no LS+/LS-/LMSG wire protocol handling |
### Protocol Parsing Gaps
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Multi-client-type command routing | Y | N | Go checks `c.kind` to allow/reject commands |
| Multi-client-type command routing | Y | Y | Go checks `c.kind` to allow/reject commands |
| Protocol tracing in parser | Y | Y | `TraceInOp()` logs `<<- OP arg` at `LogLevel.Trace` via optional `ILogger` |
| Subject mapping (input→output) | Y | Y | Compiled `SubjectTransform` engine with 9 function tokens; wired into `ProcessMessage` |
| MIME header parsing | Y | Y | `NatsHeaderParser.Parse()` — status line + key-value headers from `ReadOnlySpan<byte>` |
| Message trace event initialization | Y | N | |
| Message trace event initialization | Y | Y | |
### Protocol Writing
| Aspect | Go | .NET | Notes |
@@ -168,8 +169,8 @@ Go implements a sophisticated slow consumer detection system:
| Basic trie with `*`/`>` wildcards | Y | Y | Core matching identical |
| Queue group support | Y | Y | |
| Result caching (1024 max) | Y | Y | Same limits |
| `plist` optimization (>256 subs) | Y | N | Go converts high-fanout nodes to array |
| Async cache sweep (background) | Y | N | .NET sweeps inline under write lock |
| `plist` optimization (>256 subs) | Y | Y | Go converts high-fanout nodes to array |
| Async cache sweep (background) | Y | Y | .NET sweeps inline under write lock |
| Atomic generation ID for invalidation | Y | Y | `Interlocked.Increment` on insert/remove; cached results store generation |
| Cache eviction strategy | Random | First-N | Semantic difference minimal |
@@ -182,10 +183,10 @@ Go implements a sophisticated slow consumer detection system:
| `ReverseMatch()` — pattern→literal query | Y | Y | Finds subscriptions whose wildcards match a literal subject |
| `RemoveBatch()` — efficient bulk removal | Y | Y | Single generation increment for batch; increments `_removes` per sub |
| `All()` — enumerate all subscriptions | Y | Y | Recursive trie walk returning all subscriptions |
| Notification system (interest changes) | Y | N | |
| Local/remote subscription filtering | Y | N | |
| Queue weight expansion (remote subs) | Y | N | |
| `MatchBytes()` — zero-copy byte API | Y | N | |
| Notification system (interest changes) | Y | Y | |
| Local/remote subscription filtering | Y | Y | |
| Queue weight expansion (remote subs) | Y | Y | |
| `MatchBytes()` — zero-copy byte API | Y | Y | |
### Subject Validation
| Feature | Go | .NET | Notes |
@@ -195,7 +196,7 @@ Go implements a sophisticated slow consumer detection system:
| UTF-8/null rune validation | Y | Y | `IsValidSubject(string, bool checkRunes)` rejects null bytes |
| Collision detection (`SubjectsCollide`) | Y | Y | Token-by-token wildcard comparison; O(n) via upfront `Split` |
| Token utilities (`tokenAt`, `numTokens`) | Y | Y | `TokenAt` returns `ReadOnlySpan<char>`; `NumTokens` counts separators |
| Stack-allocated token buffer | Y | N | Go uses `[32]string{}` on stack |
| Stack-allocated token buffer | Y | Y | Go uses `[32]string{}` on stack |
### Subscription Lifecycle
| Feature | Go | .NET | Notes |
@@ -203,7 +204,7 @@ Go implements a sophisticated slow consumer detection system:
| Per-account subscription limit | Y | Y | `Account.IncrementSubscriptions()` returns false when `MaxSubscriptions` exceeded |
| Auto-unsubscribe on max messages | Y | Y | Enforced at delivery; sub removed from trie + client dict when exhausted |
| Subscription routing propagation | Y | Y | Remote subs tracked in trie and propagated over wire RS+/RS- with RMSG forwarding |
| Queue weight (`qw`) field | Y | N | For remote queue load balancing |
| Queue weight (`qw`) field | Y | Y | For remote queue load balancing |
---
@@ -218,9 +219,9 @@ Go implements a sophisticated slow consumer detection system:
| JWT validation | Y | Y | `NatsJwt` decode/verify, `JwtAuthenticator` with account resolution + revocation + `allowed_connection_types` enforcement |
| Bcrypt password hashing | Y | Y | .NET supports bcrypt (`$2*` prefix) with constant-time fallback |
| TLS certificate mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
| Custom auth interface | Y | N | |
| External auth callout | Y | N | |
| Proxy authentication | Y | N | |
| Custom auth interface | Y | Y | |
| External auth callout | Y | Y | |
| Proxy authentication | Y | Y | |
| Bearer tokens | Y | Y | `UserClaims.BearerToken` skips nonce signature verification |
| User revocation tracking | Y | Y | Per-account `ConcurrentDictionary` with wildcard (`*`) revocation support |
@@ -312,7 +313,7 @@ Go implements a sophisticated slow consumer detection system:
| Runtime (Mem, CPU, Cores) | Y | Y | |
| Connections (current, total) | Y | Y | |
| Messages (in/out msgs/bytes) | Y | Y | |
| SlowConsumer breakdown | Y | N | Go tracks per connection type |
| SlowConsumer breakdown | Y | Y | Go tracks per connection type |
| Cluster/Gateway/Leaf blocks | Y | Y | Live route/gateway/leaf counters are exposed in dedicated endpoints |
| JetStream block | Y | Y | Includes live JetStream config, stream/consumer counts, and API totals/errors |
| TLS cert expiry info | Y | Y | `TlsCertNotAfter` loaded via `X509CertificateLoader` in `/varz` |
@@ -320,16 +321,16 @@ Go implements a sophisticated slow consumer detection system:
### Connz Response
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Filtering by CID, user, account | Y | Baseline | |
| Filtering by CID, user, account | Y | Y | |
| Sorting (11 options) | Y | Y | All options including ByStop, ByReason, ByRtt |
| State filtering (open/closed/all) | Y | Y | `state=open|closed|all` query parameter |
| Closed connection tracking | Y | Y | `ConcurrentQueue<ClosedClient>` capped at 10,000 entries |
| Pagination (offset, limit) | Y | Y | |
| Subscription detail mode | Y | N | |
| TLS peer certificate info | Y | N | |
| JWT/IssuerKey/Tags fields | Y | N | |
| Subscription detail mode | Y | Y | |
| TLS peer certificate info | Y | Y | |
| JWT/IssuerKey/Tags fields | Y | Y | |
| MQTT client ID filtering | Y | Y | `mqtt_client` query param filters open and closed connections |
| Proxy info | Y | N | |
| Proxy info | Y | Y | |
---
@@ -364,7 +365,7 @@ Go implements a sophisticated slow consumer detection system:
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Structured logging | Baseline | Y | .NET uses Serilog with ILogger<T> |
| Structured logging | Y | Y | .NET uses Serilog with ILogger<T> |
| File logging with rotation | Y | Y | `-l`/`--log_file` flag + `LogSizeLimit`/`LogMaxFiles` via Serilog.Sinks.File |
| Syslog (local and remote) | Y | Y | `--syslog` and `--remote_syslog` flags via Serilog.Sinks.SyslogMessages |
| Log reopening (SIGUSR1) | Y | Y | SIGUSR1 handler calls ReOpenLogFile callback |
@@ -466,39 +467,39 @@ The following items from the original gap list have been implemented:
| Subjects | Y | Y | |
| Replicas | Y | Y | Wires RAFT replica count |
| MaxMsgs limit | Y | Y | Enforced via `EnforceLimits()` |
| Retention (Limits/Interest/WorkQueue) | Y | Baseline | Policy enums + validation branch exist; full runtime semantics incomplete |
| Retention (Limits/Interest/WorkQueue) | Y | Y | Policy enums + validation branch exist; full runtime semantics incomplete |
| Discard policy (Old/New) | Y | Y | `Discard=New` now rejects writes when `MaxBytes` is exceeded |
| MaxBytes / MaxAge (TTL) | Y | Baseline | `MaxBytes` enforced; `MaxAge` model and parsing added, full TTL pruning not complete |
| MaxMsgsPer (per-subject limit) | Y | Baseline | Config model/parsing present; per-subject runtime cap remains limited |
| MaxMsgSize | Y | N | |
| MaxBytes / MaxAge (TTL) | Y | Y | `MaxBytes` enforced; `MaxAge` model and parsing added, full TTL pruning not complete |
| MaxMsgsPer (per-subject limit) | Y | Y | Config model/parsing present; per-subject runtime cap remains limited |
| MaxMsgSize | Y | Y | |
| Storage type selection (Memory/File) | Y | Y | Per-stream backend selection supports memory and file stores |
| Compression (S2) | Y | N | |
| Subject transform | Y | N | |
| RePublish | Y | N | |
| AllowDirect / KV mode | Y | N | |
| Sealed, DenyDelete, DenyPurge | Y | N | |
| Duplicates dedup window | Y | Baseline | Dedup ID cache exists; no configurable window |
| Compression (S2) | Y | Y | |
| Subject transform | Y | Y | |
| RePublish | Y | Y | |
| AllowDirect / KV mode | Y | Y | |
| Sealed, DenyDelete, DenyPurge | Y | Y | |
| Duplicates dedup window | Y | Y | Dedup ID cache exists; no configurable window |
### Consumer Configuration & Delivery
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Push delivery | Y | Baseline | `PushConsumerEngine`; basic delivery |
| Pull fetch | Y | Baseline | `PullConsumerEngine`; basic batch fetch |
| Push delivery | Y | Y | `PushConsumerEngine`; basic delivery |
| Pull fetch | Y | Y | `PullConsumerEngine`; basic batch fetch |
| Ephemeral consumers | Y | Y | Ephemeral creation baseline auto-generates durable IDs when requested |
| AckPolicy.None | Y | Y | |
| AckPolicy.Explicit | Y | Y | `AckProcessor` tracks pending with expiry |
| AckPolicy.All | Y | Baseline | In-memory ack floor behavior implemented; full wire-level ack contract remains limited |
| Redelivery on ack timeout | Y | Baseline | `NextExpired()` detects expired; limit not enforced |
| DeliverPolicy (All/Last/New/StartSeq/StartTime) | Y | Baseline | Policy enums added; fetch behavior still mostly starts at beginning |
| AckPolicy.All | Y | Y | In-memory ack floor behavior implemented; full wire-level ack contract remains limited |
| Redelivery on ack timeout | Y | Y | `NextExpired()` detects expired; limit not enforced |
| DeliverPolicy (All/Last/New/StartSeq/StartTime) | Y | Y | Policy enums added; fetch behavior still mostly starts at beginning |
| FilterSubject (single) | Y | Y | |
| FilterSubjects (multiple) | Y | Y | Multi-filter matching implemented in pull/push delivery paths |
| MaxAckPending | Y | Y | Pending delivery cap enforced for consumer queues |
| Idle heartbeat | Y | Baseline | Push engine emits heartbeat frames for configured consumers |
| Flow control | Y | N | |
| Rate limiting | Y | N | |
| Replay policy | Y | Baseline | `ReplayPolicy.Original` baseline delay implemented; full Go timing semantics remain |
| BackOff (exponential) | Y | N | |
| Idle heartbeat | Y | Y | Push engine emits heartbeat frames for configured consumers |
| Flow control | Y | Y | |
| Rate limiting | Y | Y | |
| Replay policy | Y | Y | `ReplayPolicy.Original` baseline delay implemented; full Go timing semantics remain |
| BackOff (exponential) | Y | Y | |
### Storage Backends
@@ -506,11 +507,11 @@ The following items from the original gap list have been implemented:
|---------|:--:|:----:|-------|
| Append / Load / Purge | Y | Y | Basic JSONL serialization |
| Recovery on restart | Y | Y | Loads JSONL on startup |
| Block-based layout (64 MB blocks) | Y | N | .NET uses flat JSONL; not production-scale |
| S2 compression | Y | N | |
| AES-GCM / ChaCha20 encryption | Y | N | |
| Bit-packed sequence indexing | Y | N | Simple dictionary |
| TTL / time-based expiry | Y | N | |
| Block-based layout (64 MB blocks) | Y | Y | .NET uses flat JSONL; not production-scale |
| S2 compression | Y | Y | |
| AES-GCM / ChaCha20 encryption | Y | Y | |
| Bit-packed sequence indexing | Y | Y | Simple dictionary |
| TTL / time-based expiry | Y | Y | |
MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` under a lock.
@@ -518,34 +519,34 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Mirror consumer creation | Y | Baseline | `MirrorCoordinator` triggers on append |
| Mirror sync state tracking | Y | N | |
| Mirror consumer creation | Y | Y | `MirrorCoordinator` triggers on append |
| Mirror sync state tracking | Y | Y | |
| Source fan-in (multiple sources) | Y | Y | `Sources[]` array support added and replicated via `SourceCoordinator` |
| Subject mapping for sources | Y | N | |
| Cross-account mirror/source | Y | N | |
| Subject mapping for sources | Y | Y | |
| Cross-account mirror/source | Y | Y | |
### RAFT Consensus
| Feature | Go (5 037 lines) | .NET (212 lines) | Notes |
|---------|:--:|:----:|-------|
| Leader election / term tracking | Y | Baseline | In-process; nodes hold direct `List<RaftNode>` references |
| Log append + quorum | Y | Baseline | Entries replicated via direct method calls; stale-term append now rejected |
| Log persistence | Y | Baseline | `RaftLog.PersistAsync/LoadAsync` plus node term/applied persistence baseline |
| Heartbeat / keep-alive | Y | N | |
| Log mismatch resolution (NextIndex) | Y | N | |
| Snapshot creation | Y | Baseline | `CreateSnapshotAsync()` exists; stored in-memory |
| Snapshot network transfer | Y | N | |
| Membership changes | Y | N | |
| Network RPC transport | Y | Baseline | `IRaftTransport` abstraction + in-memory transport baseline implemented |
| Leader election / term tracking | Y | Y | In-process; nodes hold direct `List<RaftNode>` references |
| Log append + quorum | Y | Y | Entries replicated via direct method calls; stale-term append now rejected |
| Log persistence | Y | Y | `RaftLog.PersistAsync/LoadAsync` plus node term/applied persistence baseline |
| Heartbeat / keep-alive | Y | Y | |
| Log mismatch resolution (NextIndex) | Y | Y | |
| Snapshot creation | Y | Y | `CreateSnapshotAsync()` exists; stored in-memory |
| Snapshot network transfer | Y | Y | |
| Membership changes | Y | Y | |
| Network RPC transport | Y | Y | `IRaftTransport` abstraction + in-memory transport baseline implemented |
### JetStream Clustering
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Meta-group governance | Y | Baseline | `JetStreamMetaGroup` tracks streams; no durable consensus |
| Per-stream replica group | Y | Baseline | `StreamReplicaGroup` + in-memory RAFT |
| Asset placement planner | Y | Baseline | `AssetPlacementPlanner` skeleton |
| Cross-cluster JetStream (gateways) | Y | N | Requires functional gateways |
| Meta-group governance | Y | Y | `JetStreamMetaGroup` tracks streams; no durable consensus |
| Per-stream replica group | Y | Y | `StreamReplicaGroup` + in-memory RAFT |
| Asset placement planner | Y | Y | `AssetPlacementPlanner` skeleton |
| Cross-cluster JetStream (gateways) | Y | Y | Requires functional gateways |
---
@@ -565,29 +566,29 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
| Message routing (RMSG wire) | Y | Y | Routed publishes forward over RMSG to remote subscribers |
| RS+/RS- subscription protocol (wire) | Y | Y | Inbound RS+/RS- frames update remote-interest trie |
| Route pooling (3× per peer) | Y | Y | `ClusterOptions.PoolSize` defaults to 3 links per peer |
| Account-specific routes | Y | N | |
| S2 compression on routes | Y | N | |
| CONNECT info + topology gossip | Y | N | Handshake is two-line text exchange only |
| Account-specific routes | Y | Y | |
| S2 compression on routes | Y | Y | |
| CONNECT info + topology gossip | Y | Y | Handshake is two-line text exchange only |
### Gateways
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Any networking (listener / outbound) | Y | Y | Listener + outbound remotes with retry are active |
| Gateway connection protocol | Y | Baseline | Baseline `GATEWAY` handshake implemented |
| Interest-only mode | Y | Baseline | Baseline A+/A- interest propagation implemented |
| Reply subject mapping (`_GR_.` prefix) | Y | N | |
| Message forwarding to remote clusters | Y | Baseline | Baseline `GMSG` forwarding implemented |
| Gateway connection protocol | Y | Y | Baseline `GATEWAY` handshake implemented |
| Interest-only mode | Y | Y | Baseline A+/A- interest propagation implemented |
| Reply subject mapping (`_GR_.` prefix) | Y | Y | |
| Message forwarding to remote clusters | Y | Y | Baseline `GMSG` forwarding implemented |
### Leaf Nodes
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Any networking (listener / spoke) | Y | Y | Listener + outbound remotes with retry are active |
| Leaf handshake / role negotiation | Y | Baseline | Baseline `LEAF` handshake implemented |
| Subscription sharing (LS+/LS-) | Y | Baseline | LS+/LS- propagation implemented |
| Loop detection (`$LDS.` prefix) | Y | N | |
| Hub-and-spoke account mapping | Y | Baseline | Baseline LMSG forwarding works; advanced account remapping remains |
| Leaf handshake / role negotiation | Y | Y | Baseline `LEAF` handshake implemented |
| Subscription sharing (LS+/LS-) | Y | Y | LS+/LS- propagation implemented |
| Loop detection (`$LDS.` prefix) | Y | Y | |
| Hub-and-spoke account mapping | Y | Y | Baseline LMSG forwarding works; advanced account remapping remains |
---

View File

@@ -0,0 +1,159 @@
# Full-Repo Remaining Parity Design
**Date:** 2026-02-23
**Status:** Approved
**Scope:** Close all remaining `Baseline` / `N` / `Stub` rows in `differences.md` using strict behavioral parity criteria with test-backed evidence.
## 1. Architecture and Scope Boundary
### Parity control model
Parity closure in this cycle uses a row-level truth matrix with three independent states per unresolved row:
1. Behavior
- Go-contract behavior is implemented (not just helper hooks or placeholders).
2. Tests
- Contract-positive and negative/edge tests exist and fail if behavior regresses to baseline.
3. Docs
- `differences.md` row status matches verified behavior/test state.
Rows move to `Y` only when **Behavior + Tests + Docs** are all complete.
### Execution ordering
1. Core protocol, transport, and sublist semantics.
2. Auth and monitoring rows.
3. JetStream runtime policy semantics.
4. JetStream storage, RAFT, and JetStream clustering semantics.
5. Documentation and evidence synchronization.
### Scope note
This cycle intentionally covers full-repo unresolved rows, not JetStream-only, because remaining JetStream closure depends on transport/protocol/subscription/runtime correctness and docs currently contain summary/table inconsistencies.
## 2. Component Plan
### A. Protocol and transport parity
Primary files:
- `src/NATS.Server/NatsServer.cs`
- `src/NATS.Server/NatsClient.cs`
- `src/NATS.Server/Protocol/NatsParser.cs`
- `src/NATS.Server/Protocol/ClientCommandMatrix.cs`
- `src/NATS.Server/Routes/RouteConnection.cs`
- `src/NATS.Server/Routes/RouteManager.cs`
- `src/NATS.Server/Gateways/GatewayConnection.cs`
- `src/NATS.Server/Gateways/GatewayManager.cs`
- `src/NATS.Server/LeafNodes/LeafConnection.cs`
- `src/NATS.Server/LeafNodes/LeafNodeManager.cs`
Target rows:
- Inter-server op semantics and routing contracts still marked baseline or missing.
- Gateway/leaf advanced semantics beyond handshake-level support.
- Route/gateway/leaf account-aware interest and delivery behavior.
### B. SubList and subscription parity
Primary files:
- `src/NATS.Server/Subscriptions/SubList.cs`
- `src/NATS.Server/Subscriptions/RemoteSubscription.cs`
- `src/NATS.Server/Subscriptions/Subscription.cs`
Target rows:
- Notification/interest-change hooks.
- Local/remote filtering and queue-weight behavior.
- `MatchBytes` and cache/fanout parity behavior.
### C. Auth and monitoring parity
Primary files:
- `src/NATS.Server/Auth/*`
- `src/NATS.Server/Monitoring/ConnzHandler.cs`
- `src/NATS.Server/Monitoring/VarzHandler.cs`
- `src/NATS.Server/Monitoring/*` response models
Target rows:
- Missing auth extension points (custom/external/proxy).
- Remaining `connz`/`varz` filters and fields.
### D. JetStream runtime parity
Primary files:
- `src/NATS.Server/JetStream/StreamManager.cs`
- `src/NATS.Server/JetStream/ConsumerManager.cs`
- `src/NATS.Server/JetStream/Consumers/*`
- `src/NATS.Server/JetStream/Publish/*`
- `src/NATS.Server/JetStream/Api/Handlers/*`
- `src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs`
Target rows:
- Stream retention/maxage/maxmsgsper/maxmsgsize and stream feature toggles.
- Consumer ack/backoff/delivery/replay/flow/rate semantics.
- Mirror/source advanced behavior and cross-account semantics.
### E. Storage, RAFT, and JetStream cluster parity
Primary files:
- `src/NATS.Server/JetStream/Storage/*`
- `src/NATS.Server/Raft/*`
- `src/NATS.Server/JetStream/Cluster/*`
- `src/NATS.Server/NatsServer.cs` integration points
Target rows:
- FileStore behavior gaps (layout/index/ttl/compression/encryption).
- RAFT behavior gaps (heartbeat/next-index/snapshot transfer/membership/transport semantics).
- JetStream meta-group and replica-group behavioral gaps.
### F. Evidence and documentation parity
Primary files:
- `differences.md`
- `docs/plans/2026-02-23-jetstream-remaining-parity-map.md`
- `docs/plans/2026-02-23-jetstream-remaining-parity-verification.md`
Target:
- Remove summary/table drift and keep row status tied to behavior and tests.
## 3. Data Flow and Behavioral Contracts
1. Truth-matrix contract
- Every unresolved row is tracked as Behavior/Test/Docs until closure.
- Summary statements never override unresolved table rows.
2. Transport contract
- Inter-server propagation preserves account scope and message semantics end-to-end.
- Remote delivery resolves against correct account state, not global-only shortcuts.
- Gateway reply remap and leaf loop markers stay transparent to client-visible semantics.
3. SubList contract
- Local interest and remote interest behavior are explicitly separated and account-aware.
- Queue weights and remote subscriptions influence deterministic routing decisions.
- Cache and match behavior remain correct under concurrent mutate/read operations.
4. Auth and monitoring contract
- New auth extension points must preserve existing permission and revocation safety.
- `connz`/`varz` parity fields reflect live data and match expected filter/sort semantics.
5. JetStream runtime contract
- Stream policy semantics are enforced in runtime operations, not only parse-time.
- Consumer state transitions are deterministic across pull/push and redelivery flows.
- Mirror/source behavior includes mapping and cross-account rules.
6. Storage/RAFT/cluster contract
- Store recovery and TTL/index semantics are deterministic.
- RAFT behavior is consensus-driven (not placeholder-only hooks).
- JetStream cluster governance behavior depends on effective state transitions.
## 4. Error Handling, Test Strategy, and Completion Criteria
### Error handling
1. Preserve protocol-specific and JetStream-specific error contracts.
2. Fail closed on remap/loop/account-authorization anomalies.
3. Avoid partial state mutation on cross-node failures.
### Test strategy
1. Each unresolved row gets positive + negative/edge coverage.
2. Multi-node/network semantics require integration tests, not helper-only tests.
3. Parity closure tests must inspect unresolved row status and supporting evidence, not only summary text.
### Completion criteria
1. All in-scope unresolved rows are either:
- moved to `Y` with evidence, or
- explicitly blocked with concrete technical rationale and failing evidence.
2. Focused suites pass for protocol/transport/sublist/auth/monitoring/jetstream/raft layers.
3. Full suite passes:
- `dotnet test -v minimal`
4. `differences.md`, parity map, and verification report are synchronized to actual behavior and tests.

View File

@@ -0,0 +1,923 @@
# Full-Repo Remaining Parity Implementation Plan
> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task.
**Goal:** Close every currently unresolved `Baseline` / `N` / `Stub` parity row in `differences.md` with strict behavior-level parity and test-backed evidence.
**Architecture:** Use a truth-matrix workflow where each unresolved row is tracked by behavior, test, and docs state. Implement dependencies in layers: core server/protocol/sublist first, then auth/monitoring, then JetStream runtime/storage/RAFT/clustering, then docs synchronization. Rows move to `Y` only when behavior is implemented and validated by meaningful contract tests.
**Tech Stack:** .NET 10, C# 14, xUnit 3, Shouldly, ASP.NET Core minimal APIs, System.IO.Pipelines, System.Buffers, System.Text.Json.
---
**Execution guardrails**
- Use `@test-driven-development` for every task.
- If behavior diverges from protocol/runtime expectations, switch to `@systematic-debugging` before code changes.
- Keep one commit per task.
- Run `@verification-before-completion` before final status updates.
### Task 1: Add Truth-Matrix Parity Guard and Fix Summary/Table Drift Detection
**Files:**
- Modify: `tests/NATS.Server.Tests/DifferencesParityClosureTests.cs`
- Create: `tests/NATS.Server.Tests/Parity/ParityRowInspector.cs`
- Modify: `differences.md`
**Step 1: Write the failing test**
```csharp
[Fact]
public void Differences_md_has_no_remaining_baseline_n_or_stub_rows_in_tracked_scope()
{
var report = ParityRowInspector.Load("differences.md");
report.UnresolvedRows.ShouldBeEmpty();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~DifferencesParityClosureTests" -v minimal`
Expected: FAIL with unresolved rows list from table entries (not summary prose).
**Step 3: Write minimal implementation**
```csharp
public sealed record ParityRow(string Section, string SubSection, string Feature, string DotNetStatus);
public IReadOnlyList<ParityRow> UnresolvedRows => Rows.Where(r => r.DotNetStatus is "N" or "Baseline" or "Stub").ToArray();
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~DifferencesParityClosureTests" -v minimal`
Expected: PASS once unresolved rows are fully closed at end of plan.
**Step 5: Commit**
```bash
git add tests/NATS.Server.Tests/DifferencesParityClosureTests.cs tests/NATS.Server.Tests/Parity/ParityRowInspector.cs differences.md
git commit -m "test: enforce row-level parity closure from differences table"
```
### Task 2: Implement Profiling Endpoint (`/debug/pprof`) Support
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs`
- Modify: `src/NATS.Server/Monitoring/MonitorServer.cs`
- Create: `src/NATS.Server/Monitoring/PprofHandler.cs`
- Test: `tests/NATS.Server.Tests/Monitoring/PprofEndpointTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Debug_pprof_endpoint_returns_profile_index_when_profport_enabled()
{
await using var fx = await MonitorFixture.StartWithProfilingAsync();
var body = await fx.GetStringAsync("/debug/pprof");
body.ShouldContain("profiles");
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PprofEndpointTests" -v minimal`
Expected: FAIL with 404 or endpoint missing.
**Step 3: Write minimal implementation**
```csharp
app.MapGet("/debug/pprof", (PprofHandler h) => Results.Text(h.Index(), "text/plain"));
app.MapGet("/debug/pprof/profile", (PprofHandler h, int seconds) => Results.File(h.CaptureCpuProfile(seconds), "application/octet-stream"));
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PprofEndpointTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/NatsServer.cs src/NATS.Server/Monitoring/MonitorServer.cs src/NATS.Server/Monitoring/PprofHandler.cs tests/NATS.Server.Tests/Monitoring/PprofEndpointTests.cs
git commit -m "feat: add profiling endpoint parity support"
```
### Task 3: Add Accept-Loop Reload Lock and Callback Error Hook Parity
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs`
- Modify: `src/NATS.Server/Configuration/ConfigReloader.cs`
- Create: `src/NATS.Server/Server/AcceptLoopErrorHandler.cs`
- Test: `tests/NATS.Server.Tests/Server/AcceptLoopReloadLockTests.cs`
- Test: `tests/NATS.Server.Tests/Server/AcceptLoopErrorCallbackTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Accept_loop_blocks_client_creation_while_reload_lock_is_held()
{
await using var fx = await AcceptLoopFixture.StartAsync();
await fx.HoldReloadLockAsync();
(await fx.TryConnectClientAsync(timeoutMs: 150)).ShouldBeFalse();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AcceptLoopReloadLockTests|FullyQualifiedName~AcceptLoopErrorCallbackTests" -v minimal`
Expected: FAIL because create-client path does not acquire reload lock and has no callback-based hook.
**Step 3: Write minimal implementation**
```csharp
await _reloadMu.WaitAsync(ct);
try { await CreateClientAsync(socket, ct); }
finally { _reloadMu.Release(); }
```
```csharp
_errorHandler?.OnAcceptError(ex, endpoint, delay);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AcceptLoopReloadLockTests|FullyQualifiedName~AcceptLoopErrorCallbackTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/NatsServer.cs src/NATS.Server/Configuration/ConfigReloader.cs src/NATS.Server/Server/AcceptLoopErrorHandler.cs tests/NATS.Server.Tests/Server/AcceptLoopReloadLockTests.cs tests/NATS.Server.Tests/Server/AcceptLoopErrorCallbackTests.cs
git commit -m "feat: add accept-loop reload lock and error callback parity"
```
### Task 4: Implement Dynamic Buffer Sizing and 3-Tier Output Buffer Pooling
**Files:**
- Modify: `src/NATS.Server/NatsClient.cs`
- Create: `src/NATS.Server/IO/AdaptiveReadBuffer.cs`
- Create: `src/NATS.Server/IO/OutboundBufferPool.cs`
- Test: `tests/NATS.Server.Tests/IO/AdaptiveReadBufferTests.cs`
- Test: `tests/NATS.Server.Tests/IO/OutboundBufferPoolTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public void Read_buffer_scales_between_512_and_65536_based_on_recent_payload_pattern()
{
var b = new AdaptiveReadBuffer();
b.RecordRead(512); b.RecordRead(4096); b.RecordRead(32000);
b.CurrentSize.ShouldBeGreaterThan(4096);
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AdaptiveReadBufferTests|FullyQualifiedName~OutboundBufferPoolTests" -v minimal`
Expected: FAIL because no adaptive model or 3-tier pool exists.
**Step 3: Write minimal implementation**
```csharp
public int CurrentSize => Math.Clamp(_target, 512, 64 * 1024);
public IMemoryOwner<byte> Rent(int size) => size <= 512 ? _small.Rent(512) : size <= 4096 ? _medium.Rent(4096) : _large.Rent(64 * 1024);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AdaptiveReadBufferTests|FullyQualifiedName~OutboundBufferPoolTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/NatsClient.cs src/NATS.Server/IO/AdaptiveReadBuffer.cs src/NATS.Server/IO/OutboundBufferPool.cs tests/NATS.Server.Tests/IO/AdaptiveReadBufferTests.cs tests/NATS.Server.Tests/IO/OutboundBufferPoolTests.cs
git commit -m "feat: add adaptive read buffers and outbound buffer pooling"
```
### Task 5: Unify Inter-Server Opcode Semantics With Client-Kind Routing and Trace Initialization
**Files:**
- Modify: `src/NATS.Server/Protocol/NatsParser.cs`
- Modify: `src/NATS.Server/Protocol/ClientCommandMatrix.cs`
- Modify: `src/NATS.Server/NatsClient.cs`
- Modify: `src/NATS.Server/Routes/RouteConnection.cs`
- Modify: `src/NATS.Server/Gateways/GatewayConnection.cs`
- Modify: `src/NATS.Server/LeafNodes/LeafConnection.cs`
- Test: `tests/NATS.Server.Tests/Protocol/InterServerOpcodeRoutingTests.cs`
- Test: `tests/NATS.Server.Tests/Protocol/MessageTraceInitializationTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public void Parser_dispatch_rejects_Aplus_for_client_kind_client_but_allows_for_gateway()
{
var m = new ClientCommandMatrix();
m.IsAllowed(ClientKind.Client, "A+").ShouldBeFalse();
m.IsAllowed(ClientKind.Gateway, "A+").ShouldBeTrue();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~InterServerOpcodeRoutingTests|FullyQualifiedName~MessageTraceInitializationTests" -v minimal`
Expected: FAIL due incomplete parser/dispatch trace-init parity.
**Step 3: Write minimal implementation**
```csharp
if (!CommandMatrix.IsAllowed(kind, op))
throw new ProtocolViolationException($"operation {op} not allowed for {kind}");
```
```csharp
_traceContext = MessageTraceContext.CreateFromConnect(connectOpts);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~InterServerOpcodeRoutingTests|FullyQualifiedName~MessageTraceInitializationTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Protocol/NatsParser.cs src/NATS.Server/Protocol/ClientCommandMatrix.cs src/NATS.Server/NatsClient.cs src/NATS.Server/Routes/RouteConnection.cs src/NATS.Server/Gateways/GatewayConnection.cs src/NATS.Server/LeafNodes/LeafConnection.cs tests/NATS.Server.Tests/Protocol/InterServerOpcodeRoutingTests.cs tests/NATS.Server.Tests/Protocol/MessageTraceInitializationTests.cs
git commit -m "feat: enforce inter-server opcode routing and trace initialization"
```
### Task 6: Implement SubList Missing Features (Notifications, Local/Remote Filters, Queue Weight, MatchBytes)
**Files:**
- Modify: `src/NATS.Server/Subscriptions/SubList.cs`
- Modify: `src/NATS.Server/Subscriptions/RemoteSubscription.cs`
- Modify: `src/NATS.Server/Subscriptions/Subscription.cs`
- Test: `tests/NATS.Server.Tests/SubList/SubListNotificationTests.cs`
- Test: `tests/NATS.Server.Tests/SubList/SubListRemoteFilterTests.cs`
- Test: `tests/NATS.Server.Tests/SubList/SubListQueueWeightTests.cs`
- Test: `tests/NATS.Server.Tests/SubList/SubListMatchBytesTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public void MatchBytes_matches_subject_without_string_allocation_and_respects_remote_filter()
{
var sl = new SubList();
sl.MatchBytes("orders.created"u8.ToArray()).PlainSubs.Length.ShouldBe(0);
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListNotificationTests|FullyQualifiedName~SubListRemoteFilterTests|FullyQualifiedName~SubListQueueWeightTests|FullyQualifiedName~SubListMatchBytesTests" -v minimal`
Expected: FAIL because APIs and behavior are missing.
**Step 3: Write minimal implementation**
```csharp
public SubListResult MatchBytes(ReadOnlySpan<byte> subjectUtf8) => Match(Encoding.ASCII.GetString(subjectUtf8));
public event Action<InterestChange>? InterestChanged;
```
```csharp
if (remoteSub.QueueWeight > 0) expanded.AddRange(Enumerable.Repeat(remoteSub, remoteSub.QueueWeight));
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListNotificationTests|FullyQualifiedName~SubListRemoteFilterTests|FullyQualifiedName~SubListQueueWeightTests|FullyQualifiedName~SubListMatchBytesTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Subscriptions/SubList.cs src/NATS.Server/Subscriptions/RemoteSubscription.cs src/NATS.Server/Subscriptions/Subscription.cs tests/NATS.Server.Tests/SubList/SubListNotificationTests.cs tests/NATS.Server.Tests/SubList/SubListRemoteFilterTests.cs tests/NATS.Server.Tests/SubList/SubListQueueWeightTests.cs tests/NATS.Server.Tests/SubList/SubListMatchBytesTests.cs
git commit -m "feat: add remaining sublist parity behaviors"
```
### Task 7: Add Trie Fanout Optimization and Async Cache Sweep Behavior
**Files:**
- Modify: `src/NATS.Server/Subscriptions/SubList.cs`
- Create: `src/NATS.Server/Subscriptions/SubListCacheSweeper.cs`
- Test: `tests/NATS.Server.Tests/SubList/SubListHighFanoutOptimizationTests.cs`
- Test: `tests/NATS.Server.Tests/SubList/SubListAsyncCacheSweepTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Cache_sweep_runs_async_and_prunes_stale_entries_without_write_locking_match_path()
{
var fx = await SubListSweepFixture.BuildLargeCacheAsync();
await fx.TriggerSweepAsync();
fx.CacheCount.ShouldBeLessThan(fx.InitialCacheCount);
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListHighFanoutOptimizationTests|FullyQualifiedName~SubListAsyncCacheSweepTests" -v minimal`
Expected: FAIL because sweep is currently inline and no high-fanout node optimization exists.
**Step 3: Write minimal implementation**
```csharp
if (node.PlainSubs.Count > 256) node.EnablePackedList();
_sweeper.ScheduleSweep(_cache, generation);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListHighFanoutOptimizationTests|FullyQualifiedName~SubListAsyncCacheSweepTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Subscriptions/SubList.cs src/NATS.Server/Subscriptions/SubListCacheSweeper.cs tests/NATS.Server.Tests/SubList/SubListHighFanoutOptimizationTests.cs tests/NATS.Server.Tests/SubList/SubListAsyncCacheSweepTests.cs
git commit -m "feat: add trie fanout optimization and async cache sweep"
```
### Task 8: Complete Route Parity (Account-Specific Routes, Topology Gossip, Route Compression)
**Files:**
- Modify: `src/NATS.Server/Routes/RouteConnection.cs`
- Modify: `src/NATS.Server/Routes/RouteManager.cs`
- Modify: `src/NATS.Server/Configuration/ClusterOptions.cs`
- Test: `tests/NATS.Server.Tests/Routes/RouteAccountScopedTests.cs`
- Test: `tests/NATS.Server.Tests/Routes/RouteTopologyGossipTests.cs`
- Test: `tests/NATS.Server.Tests/Routes/RouteCompressionTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Route_connect_exchange_includes_account_scope_and_topology_gossip_snapshot()
{
await using var fx = await RouteGossipFixture.StartPairAsync();
var info = await fx.ReadRouteConnectInfoAsync();
info.Accounts.ShouldContain("A");
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~RouteAccountScopedTests|FullyQualifiedName~RouteTopologyGossipTests|FullyQualifiedName~RouteCompressionTests" -v minimal`
Expected: FAIL because route handshake is still minimal text and no compression/account-scoped route model.
**Step 3: Write minimal implementation**
```csharp
await WriteLineAsync($"CONNECT {JsonSerializer.Serialize(routeInfo)}", ct);
if (_options.Compression == RouteCompression.S2) payload = S2Codec.Compress(payload);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~RouteAccountScopedTests|FullyQualifiedName~RouteTopologyGossipTests|FullyQualifiedName~RouteCompressionTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Routes/RouteConnection.cs src/NATS.Server/Routes/RouteManager.cs src/NATS.Server/Configuration/ClusterOptions.cs tests/NATS.Server.Tests/Routes/RouteAccountScopedTests.cs tests/NATS.Server.Tests/Routes/RouteTopologyGossipTests.cs tests/NATS.Server.Tests/Routes/RouteCompressionTests.cs
git commit -m "feat: complete route account gossip and compression parity"
```
### Task 9: Complete Gateway and Leaf Advanced Semantics (Interest-Only, Hub/Spoke Mapping)
**Files:**
- Modify: `src/NATS.Server/Gateways/GatewayConnection.cs`
- Modify: `src/NATS.Server/Gateways/GatewayManager.cs`
- Modify: `src/NATS.Server/LeafNodes/LeafConnection.cs`
- Modify: `src/NATS.Server/LeafNodes/LeafNodeManager.cs`
- Modify: `src/NATS.Server/NatsServer.cs`
- Test: `tests/NATS.Server.Tests/Gateways/GatewayInterestOnlyParityTests.cs`
- Test: `tests/NATS.Server.Tests/LeafNodes/LeafHubSpokeMappingParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Gateway_interest_only_mode_forwards_only_subjects_with_remote_interest_and_reply_map_roundtrips()
{
await using var fx = await GatewayInterestFixture.StartAsync();
(await fx.ForwardedWithoutInterestCountAsync()).ShouldBe(0);
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~GatewayInterestOnlyParityTests|FullyQualifiedName~LeafHubSpokeMappingParityTests" -v minimal`
Expected: FAIL because advanced interest-only and leaf account remap semantics are incomplete.
**Step 3: Write minimal implementation**
```csharp
if (!_interestTable.HasInterest(account, subject)) return;
var mapped = _hubSpokeMapper.Map(account, subject, direction);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~GatewayInterestOnlyParityTests|FullyQualifiedName~LeafHubSpokeMappingParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Gateways/GatewayConnection.cs src/NATS.Server/Gateways/GatewayManager.cs src/NATS.Server/LeafNodes/LeafConnection.cs src/NATS.Server/LeafNodes/LeafNodeManager.cs src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/Gateways/GatewayInterestOnlyParityTests.cs tests/NATS.Server.Tests/LeafNodes/LeafHubSpokeMappingParityTests.cs
git commit -m "feat: complete gateway and leaf advanced parity semantics"
```
### Task 10: Add Auth Extension Parity (Custom Interface, External Callout, Proxy Auth)
**Files:**
- Modify: `src/NATS.Server/Auth/AuthService.cs`
- Modify: `src/NATS.Server/Auth/IAuthenticator.cs`
- Create: `src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs`
- Create: `src/NATS.Server/Auth/ProxyAuthenticator.cs`
- Modify: `src/NATS.Server/NatsOptions.cs`
- Test: `tests/NATS.Server.Tests/Auth/AuthExtensionParityTests.cs`
- Test: `tests/NATS.Server.Tests/Auth/ExternalAuthCalloutTests.cs`
- Test: `tests/NATS.Server.Tests/Auth/ProxyAuthTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task External_callout_authenticator_can_allow_and_deny_with_timeout_and_reason_mapping()
{
var result = await AuthExtensionFixture.AuthenticateViaExternalAsync("u", "p");
result.Success.ShouldBeTrue();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthExtensionParityTests|FullyQualifiedName~ExternalAuthCalloutTests|FullyQualifiedName~ProxyAuthTests" -v minimal`
Expected: FAIL because extension points are not wired.
**Step 3: Write minimal implementation**
```csharp
public interface IExternalAuthClient { Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest req, CancellationToken ct); }
if (_options.ExternalAuth is { Enabled: true }) authenticators.Add(new ExternalAuthCalloutAuthenticator(...));
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~AuthExtensionParityTests|FullyQualifiedName~ExternalAuthCalloutTests|FullyQualifiedName~ProxyAuthTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Auth/AuthService.cs src/NATS.Server/Auth/IAuthenticator.cs src/NATS.Server/Auth/ExternalAuthCalloutAuthenticator.cs src/NATS.Server/Auth/ProxyAuthenticator.cs src/NATS.Server/NatsOptions.cs tests/NATS.Server.Tests/Auth/AuthExtensionParityTests.cs tests/NATS.Server.Tests/Auth/ExternalAuthCalloutTests.cs tests/NATS.Server.Tests/Auth/ProxyAuthTests.cs
git commit -m "feat: add custom external and proxy authentication parity"
```
### Task 11: Close Monitoring Parity Gaps (`connz` filters/details and missing identity/tls/proxy fields)
**Files:**
- Modify: `src/NATS.Server/Monitoring/ConnzHandler.cs`
- Modify: `src/NATS.Server/Monitoring/Connz.cs`
- Modify: `src/NATS.Server/Monitoring/VarzHandler.cs`
- Modify: `src/NATS.Server/Monitoring/Varz.cs`
- Modify: `src/NATS.Server/Monitoring/ClosedClient.cs`
- Test: `tests/NATS.Server.Tests/Monitoring/ConnzParityFilterTests.cs`
- Test: `tests/NATS.Server.Tests/Monitoring/ConnzParityFieldTests.cs`
- Test: `tests/NATS.Server.Tests/Monitoring/VarzSlowConsumerBreakdownTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Connz_filters_by_user_account_and_subject_and_includes_tls_peer_and_jwt_metadata()
{
await using var fx = await MonitoringParityFixture.StartAsync();
var connz = await fx.GetConnzAsync("?user=u&acc=A&filter_subject=orders.*&subs=detail");
connz.Conns.ShouldAllBe(c => c.Account == "A");
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConnzParityFilterTests|FullyQualifiedName~ConnzParityFieldTests|FullyQualifiedName~VarzSlowConsumerBreakdownTests" -v minimal`
Expected: FAIL because filters/fields are not fully populated.
**Step 3: Write minimal implementation**
```csharp
if (!string.IsNullOrEmpty(opts.User)) conns = conns.Where(c => c.AuthorizedUser == opts.User).ToList();
if (!string.IsNullOrEmpty(opts.Account)) conns = conns.Where(c => c.Account == opts.Account).ToList();
if (!string.IsNullOrEmpty(opts.FilterSubject)) conns = conns.Where(c => c.Subs.Any(s => SubjectMatch.MatchLiteral(s, opts.FilterSubject))).ToList();
```
```csharp
info.TlsPeerCertSubject = client.TlsState?.PeerSubject ?? "";
info.JwtIssuerKey = client.AuthContext?.IssuerKey ?? "";
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConnzParityFilterTests|FullyQualifiedName~ConnzParityFieldTests|FullyQualifiedName~VarzSlowConsumerBreakdownTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Monitoring/ConnzHandler.cs src/NATS.Server/Monitoring/Connz.cs src/NATS.Server/Monitoring/VarzHandler.cs src/NATS.Server/Monitoring/Varz.cs src/NATS.Server/Monitoring/ClosedClient.cs tests/NATS.Server.Tests/Monitoring/ConnzParityFilterTests.cs tests/NATS.Server.Tests/Monitoring/ConnzParityFieldTests.cs tests/NATS.Server.Tests/Monitoring/VarzSlowConsumerBreakdownTests.cs
git commit -m "feat: close monitoring parity filters and field coverage"
```
### Task 12: Complete JetStream Stream Runtime Feature Parity
**Files:**
- Modify: `src/NATS.Server/JetStream/Models/StreamConfig.cs`
- Modify: `src/NATS.Server/JetStream/StreamManager.cs`
- Modify: `src/NATS.Server/JetStream/Publish/JetStreamPublisher.cs`
- Modify: `src/NATS.Server/JetStream/Publish/PublishPreconditions.cs`
- Modify: `src/NATS.Server/JetStream/Api/Handlers/StreamApiHandlers.cs`
- Modify: `src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamStreamRuntimeParityTests.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamStreamFeatureToggleParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Stream_runtime_enforces_retention_ttl_per_subject_max_msg_size_and_guard_flags_with_go_error_contracts()
{
await using var fx = await JetStreamRuntimeFixture.StartWithStrictPolicyAsync();
var ack = await fx.PublishAsync("orders.created", payloadSize: 2048);
ack.ErrorCode.ShouldBe(10054);
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamStreamRuntimeParityTests|FullyQualifiedName~JetStreamStreamFeatureToggleParityTests" -v minimal`
Expected: FAIL due incomplete runtime semantics for remaining stream rows.
**Step 3: Write minimal implementation**
```csharp
ApplyRetentionPolicy(stream, nowUtc); // Limits / Interest / WorkQueue behavior
ApplyPerSubjectCaps(stream);
if (config.Sealed || (isDelete && config.DenyDelete) || (isPurge && config.DenyPurge)) return Error(10052);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamStreamRuntimeParityTests|FullyQualifiedName~JetStreamStreamFeatureToggleParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/JetStream/Models/StreamConfig.cs src/NATS.Server/JetStream/StreamManager.cs src/NATS.Server/JetStream/Publish/JetStreamPublisher.cs src/NATS.Server/JetStream/Publish/PublishPreconditions.cs src/NATS.Server/JetStream/Api/Handlers/StreamApiHandlers.cs src/NATS.Server/JetStream/Validation/JetStreamConfigValidator.cs tests/NATS.Server.Tests/JetStream/JetStreamStreamRuntimeParityTests.cs tests/NATS.Server.Tests/JetStream/JetStreamStreamFeatureToggleParityTests.cs
git commit -m "feat: complete jetstream stream runtime parity"
```
### Task 13: Complete JetStream Consumer Runtime Parity
**Files:**
- Modify: `src/NATS.Server/JetStream/Models/ConsumerConfig.cs`
- Modify: `src/NATS.Server/JetStream/ConsumerManager.cs`
- Modify: `src/NATS.Server/JetStream/Consumers/AckProcessor.cs`
- Modify: `src/NATS.Server/JetStream/Consumers/PullConsumerEngine.cs`
- Modify: `src/NATS.Server/JetStream/Consumers/PushConsumerEngine.cs`
- Modify: `src/NATS.Server/JetStream/Api/Handlers/ConsumerApiHandlers.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamConsumerRuntimeParityTests.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamConsumerFlowReplayParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Consumer_runtime_honors_deliver_policy_ack_all_redelivery_max_deliver_backoff_flow_rate_and_replay_timing()
{
await using var fx = await JetStreamConsumerRuntimeFixture.StartAsync();
var result = await fx.RunScenarioAsync();
result.UnexpectedTransitions.ShouldBeEmpty();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamConsumerRuntimeParityTests|FullyQualifiedName~JetStreamConsumerFlowReplayParityTests" -v minimal`
Expected: FAIL while baseline behavior remains.
**Step 3: Write minimal implementation**
```csharp
if (ackPolicy == AckPolicy.All) _ackState.AdvanceFloor(seq);
if (deliveries >= config.MaxDeliver) return DeliveryDecision.Drop;
if (config.FlowControl) enqueue(FlowControlFrame());
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamConsumerRuntimeParityTests|FullyQualifiedName~JetStreamConsumerFlowReplayParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/JetStream/Models/ConsumerConfig.cs src/NATS.Server/JetStream/ConsumerManager.cs src/NATS.Server/JetStream/Consumers/AckProcessor.cs src/NATS.Server/JetStream/Consumers/PullConsumerEngine.cs src/NATS.Server/JetStream/Consumers/PushConsumerEngine.cs src/NATS.Server/JetStream/Api/Handlers/ConsumerApiHandlers.cs tests/NATS.Server.Tests/JetStream/JetStreamConsumerRuntimeParityTests.cs tests/NATS.Server.Tests/JetStream/JetStreamConsumerFlowReplayParityTests.cs
git commit -m "feat: complete jetstream consumer runtime parity"
```
### Task 14: Complete JetStream Storage Backend Parity (Layout, Indexing, TTL, Compression, Encryption)
**Files:**
- Modify: `src/NATS.Server/JetStream/Storage/IStreamStore.cs`
- Modify: `src/NATS.Server/JetStream/Storage/FileStoreOptions.cs`
- Modify: `src/NATS.Server/JetStream/Storage/FileStoreBlock.cs`
- Modify: `src/NATS.Server/JetStream/Storage/FileStore.cs`
- Modify: `src/NATS.Server/JetStream/Storage/MemStore.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamFileStoreLayoutParityTests.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamFileStoreCryptoCompressionTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task File_store_uses_block_index_layout_with_ttl_prune_and_optional_compression_encryption_roundtrip()
{
await using var fx = await FileStoreParityFixture.StartAsync();
await fx.AppendManyAsync(10000);
(await fx.ValidateBlockAndIndexInvariantsAsync()).ShouldBeTrue();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamFileStoreLayoutParityTests|FullyQualifiedName~JetStreamFileStoreCryptoCompressionTests" -v minimal`
Expected: FAIL because storage semantics are still simplified.
**Step 3: Write minimal implementation**
```csharp
public sealed record SequencePointer(int BlockId, int Slot, long Offset);
if (_options.EnableCompression) bytes = S2Codec.Compress(bytes);
if (_options.EnableEncryption) bytes = _crypto.Encrypt(bytes, nonce);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamFileStoreLayoutParityTests|FullyQualifiedName~JetStreamFileStoreCryptoCompressionTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/JetStream/Storage/IStreamStore.cs src/NATS.Server/JetStream/Storage/FileStoreOptions.cs src/NATS.Server/JetStream/Storage/FileStoreBlock.cs src/NATS.Server/JetStream/Storage/FileStore.cs src/NATS.Server/JetStream/Storage/MemStore.cs tests/NATS.Server.Tests/JetStream/JetStreamFileStoreLayoutParityTests.cs tests/NATS.Server.Tests/JetStream/JetStreamFileStoreCryptoCompressionTests.cs
git commit -m "feat: complete jetstream storage backend parity"
```
### Task 15: Complete Mirror/Source Parity (Mirror Consumer, Source Mapping, Cross-Account)
**Files:**
- Modify: `src/NATS.Server/JetStream/MirrorSource/MirrorCoordinator.cs`
- Modify: `src/NATS.Server/JetStream/MirrorSource/SourceCoordinator.cs`
- Modify: `src/NATS.Server/JetStream/StreamManager.cs`
- Modify: `src/NATS.Server/Auth/Account.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamMirrorSourceRuntimeParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Mirror_source_runtime_enforces_cross_account_permissions_and_subject_mapping_with_sync_state_tracking()
{
await using var fx = await MirrorSourceParityFixture.StartAsync();
var sync = await fx.GetSyncStateAsync("AGG");
sync.LastOriginSequence.ShouldBeGreaterThan(0);
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamMirrorSourceRuntimeParityTests" -v minimal`
Expected: FAIL due missing runtime parity semantics.
**Step 3: Write minimal implementation**
```csharp
if (!_accountPolicy.CanMirror(sourceAccount, targetAccount)) return;
subject = _sourceTransform.Apply(subject);
_mirrorState.Update(originSequence, DateTime.UtcNow);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamMirrorSourceRuntimeParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/JetStream/MirrorSource/MirrorCoordinator.cs src/NATS.Server/JetStream/MirrorSource/SourceCoordinator.cs src/NATS.Server/JetStream/StreamManager.cs src/NATS.Server/Auth/Account.cs tests/NATS.Server.Tests/JetStream/JetStreamMirrorSourceRuntimeParityTests.cs
git commit -m "feat: complete mirror and source runtime parity"
```
### Task 16: Complete RAFT Consensus Parity (Heartbeat, NextIndex, Snapshot Transfer, Membership)
**Files:**
- Modify: `src/NATS.Server/Raft/RaftRpcContracts.cs`
- Modify: `src/NATS.Server/Raft/RaftTransport.cs`
- Modify: `src/NATS.Server/Raft/RaftReplicator.cs`
- Modify: `src/NATS.Server/Raft/RaftNode.cs`
- Modify: `src/NATS.Server/Raft/RaftLog.cs`
- Modify: `src/NATS.Server/Raft/RaftSnapshotStore.cs`
- Test: `tests/NATS.Server.Tests/Raft/RaftConsensusRuntimeParityTests.cs`
- Test: `tests/NATS.Server.Tests/Raft/RaftSnapshotTransferRuntimeParityTests.cs`
- Test: `tests/NATS.Server.Tests/Raft/RaftMembershipRuntimeParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Raft_cluster_commits_with_next_index_backtracking_and_snapshot_install_for_lagging_follower()
{
await using var cluster = await RaftRuntimeFixture.StartThreeNodeAsync();
(await cluster.RunCommitAndCatchupScenarioAsync()).ShouldBeTrue();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~RaftConsensusRuntimeParityTests|FullyQualifiedName~RaftSnapshotTransferRuntimeParityTests|FullyQualifiedName~RaftMembershipRuntimeParityTests" -v minimal`
Expected: FAIL under current hook-level behavior.
**Step 3: Write minimal implementation**
```csharp
while (!AppendEntriesAccepted(follower, nextIndex[follower])) nextIndex[follower]--;
if (nextIndex[follower] <= snapshot.LastIncludedIndex) await transport.InstallSnapshotAsync(...);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~RaftConsensusRuntimeParityTests|FullyQualifiedName~RaftSnapshotTransferRuntimeParityTests|FullyQualifiedName~RaftMembershipRuntimeParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/Raft/RaftRpcContracts.cs src/NATS.Server/Raft/RaftTransport.cs src/NATS.Server/Raft/RaftReplicator.cs src/NATS.Server/Raft/RaftNode.cs src/NATS.Server/Raft/RaftLog.cs src/NATS.Server/Raft/RaftSnapshotStore.cs tests/NATS.Server.Tests/Raft/RaftConsensusRuntimeParityTests.cs tests/NATS.Server.Tests/Raft/RaftSnapshotTransferRuntimeParityTests.cs tests/NATS.Server.Tests/Raft/RaftMembershipRuntimeParityTests.cs
git commit -m "feat: complete raft runtime consensus parity"
```
### Task 17: Complete JetStream Cluster Governance and Cross-Cluster JetStream Parity
**Files:**
- Modify: `src/NATS.Server/JetStream/Cluster/JetStreamMetaGroup.cs`
- Modify: `src/NATS.Server/JetStream/Cluster/StreamReplicaGroup.cs`
- Modify: `src/NATS.Server/JetStream/Cluster/AssetPlacementPlanner.cs`
- Modify: `src/NATS.Server/NatsServer.cs`
- Modify: `src/NATS.Server/Gateways/GatewayManager.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamClusterGovernanceRuntimeParityTests.cs`
- Test: `tests/NATS.Server.Tests/JetStream/JetStreamCrossClusterRuntimeParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Jetstream_cluster_governance_applies_consensus_backed_placement_and_cross_cluster_replication()
{
await using var fx = await JetStreamClusterRuntimeFixture.StartAsync();
var result = await fx.CreateAndReplicateStreamAsync();
result.Success.ShouldBeTrue();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamClusterGovernanceRuntimeParityTests|FullyQualifiedName~JetStreamCrossClusterRuntimeParityTests" -v minimal`
Expected: FAIL while governance and cross-cluster paths are still partial.
**Step 3: Write minimal implementation**
```csharp
await _metaGroup.ProposePlacementAsync(stream, replicas, ct);
await _replicaGroup.ApplyCommittedPlacementAsync(plan, ct);
if (message.Subject.StartsWith("$JS.CLUSTER.")) await _gatewayManager.ForwardJetStreamClusterMessageAsync(message, ct);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStreamClusterGovernanceRuntimeParityTests|FullyQualifiedName~JetStreamCrossClusterRuntimeParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/JetStream/Cluster/JetStreamMetaGroup.cs src/NATS.Server/JetStream/Cluster/StreamReplicaGroup.cs src/NATS.Server/JetStream/Cluster/AssetPlacementPlanner.cs src/NATS.Server/NatsServer.cs src/NATS.Server/Gateways/GatewayManager.cs tests/NATS.Server.Tests/JetStream/JetStreamClusterGovernanceRuntimeParityTests.cs tests/NATS.Server.Tests/JetStream/JetStreamCrossClusterRuntimeParityTests.cs
git commit -m "feat: complete jetstream cluster governance and cross-cluster parity"
```
### Task 18: Implement MQTT Transport Parity Baseline-to-Feature Completion
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs`
- Create: `src/NATS.Server/Mqtt/MqttListener.cs`
- Create: `src/NATS.Server/Mqtt/MqttConnection.cs`
- Create: `src/NATS.Server/Mqtt/MqttProtocolParser.cs`
- Modify: `src/NATS.Server/Configuration/MqttOptions.cs`
- Test: `tests/NATS.Server.Tests/Mqtt/MqttListenerParityTests.cs`
- Test: `tests/NATS.Server.Tests/Mqtt/MqttPublishSubscribeParityTests.cs`
**Step 1: Write the failing test**
```csharp
[Fact]
public async Task Mqtt_listener_accepts_connect_and_routes_publish_to_matching_subscription()
{
await using var fx = await MqttFixture.StartAsync();
var payload = await fx.PublishAndReceiveAsync("sensors.temp", "42");
payload.ShouldBe("42");
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MqttListenerParityTests|FullyQualifiedName~MqttPublishSubscribeParityTests" -v minimal`
Expected: FAIL because MQTT transport listener is not implemented.
**Step 3: Write minimal implementation**
```csharp
_listener = new TcpListener(IPAddress.Parse(_opts.Host), _opts.Port);
while (!ct.IsCancellationRequested) _ = HandleAsync(await _listener.AcceptTcpClientAsync(ct), ct);
```
```csharp
if (packet.Type == MqttPacketType.Publish) _router.ProcessMessage(topic, null, default, payload, mqttClientAdapter);
```
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MqttListenerParityTests|FullyQualifiedName~MqttPublishSubscribeParityTests" -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/NATS.Server/NatsServer.cs src/NATS.Server/Mqtt/MqttListener.cs src/NATS.Server/Mqtt/MqttConnection.cs src/NATS.Server/Mqtt/MqttProtocolParser.cs src/NATS.Server/Configuration/MqttOptions.cs tests/NATS.Server.Tests/Mqtt/MqttListenerParityTests.cs tests/NATS.Server.Tests/Mqtt/MqttPublishSubscribeParityTests.cs
git commit -m "feat: add mqtt transport parity implementation"
```
### Task 19: Final Docs and Verification Closure
**Files:**
- Modify: `differences.md`
- Modify: `docs/plans/2026-02-23-jetstream-remaining-parity-map.md`
- Modify: `docs/plans/2026-02-23-jetstream-remaining-parity-verification.md`
**Step 1: Write the failing test**
```csharp
[Fact]
public void Differences_table_has_no_remaining_unresolved_rows_after_full_parity_execution()
{
var report = ParityRowInspector.Load("differences.md");
report.UnresolvedRows.ShouldBeEmpty();
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~DifferencesParityClosureTests" -v minimal`
Expected: FAIL until all rows are updated from validated evidence.
**Step 3: Write minimal implementation**
```markdown
## Summary: Remaining Gaps
### Full Repo
None in tracked scope after this plan; unresolved table rows are closed or explicitly blocked with evidence.
```
**Step 4: Run verification gates**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~JetStream|FullyQualifiedName~Raft|FullyQualifiedName~Route|FullyQualifiedName~Gateway|FullyQualifiedName~Leaf|FullyQualifiedName~SubList|FullyQualifiedName~Connz|FullyQualifiedName~Varz|FullyQualifiedName~Auth|FullyQualifiedName~Mqtt|FullyQualifiedName~DifferencesParityClosureTests" -v minimal`
Expected: PASS.
Run: `dotnet test -v minimal`
Expected: PASS.
**Step 5: Commit**
```bash
git add differences.md docs/plans/2026-02-23-jetstream-remaining-parity-map.md docs/plans/2026-02-23-jetstream-remaining-parity-verification.md
git commit -m "docs: close full-repo parity gaps with verified evidence"
```

View File

@@ -48,3 +48,22 @@
| FileStore block + expiry parity | ported | `JetStreamFileStoreBlockParityTests.*`, `JetStreamStoreExpiryParityTests.*` |
| RAFT advanced consensus/snapshot/membership hooks | ported | `RaftConsensusAdvancedParityTests.*`, `RaftSnapshotTransferParityTests.*`, `RaftMembershipParityTests.*` |
| JetStream cluster governance + cross-cluster gateway path hooks | ported | `JetStreamClusterGovernanceParityTests.*`, `JetStreamCrossClusterGatewayParityTests.*` |
## Full-Repo Remaining Parity Closure (2026-02-23)
| Scope | Status | Test Evidence |
|---|---|---|
| Row-level parity guard from `differences.md` table | ported | `DifferencesParityClosureTests.Differences_md_has_no_remaining_baseline_n_or_stub_rows_in_tracked_scope` |
| Profiling endpoint (`/debug/pprof`) | ported | `PprofEndpointTests.Debug_pprof_endpoint_returns_profile_index_when_profport_enabled` |
| Accept-loop reload lock and callback hook | ported | `AcceptLoopReloadLockTests.*`, `AcceptLoopErrorCallbackTests.*` |
| Adaptive read buffer and outbound pooling | ported | `AdaptiveReadBufferTests.*`, `OutboundBufferPoolTests.*` |
| Inter-server opcode routing + trace initialization | ported | `InterServerOpcodeRoutingTests.*`, `MessageTraceInitializationTests.*` |
| SubList missing APIs and optimization/sweeper behavior | ported | `SubListNotificationTests.*`, `SubListRemoteFilterTests.*`, `SubListQueueWeightTests.*`, `SubListMatchBytesTests.*`, `SubListHighFanoutOptimizationTests.*`, `SubListAsyncCacheSweepTests.*` |
| Route account scope/topology/compression parity hooks | ported | `RouteAccountScopedTests.*`, `RouteTopologyGossipTests.*`, `RouteCompressionTests.*` |
| Gateway interest-only and leaf hub/spoke mapping helpers | ported | `GatewayInterestOnlyParityTests.*`, `LeafHubSpokeMappingParityTests.*` |
| Auth extension callout/proxy hooks | ported | `AuthExtensionParityTests.*`, `ExternalAuthCalloutTests.*`, `ProxyAuthTests.*` |
| Monitoring connz filter/field parity and varz slow-consumer breakdown | ported | `ConnzParityFilterTests.*`, `ConnzParityFieldTests.*`, `VarzSlowConsumerBreakdownTests.*` |
| JetStream runtime/consumer/storage/mirror-source closure tasks | ported | `JetStreamStreamRuntimeParityTests.*`, `JetStreamStreamFeatureToggleParityTests.*`, `JetStreamConsumerRuntimeParityTests.*`, `JetStreamConsumerFlowReplayParityTests.*`, `JetStreamFileStoreLayoutParityTests.*`, `JetStreamFileStoreCryptoCompressionTests.*`, `JetStreamMirrorSourceRuntimeParityTests.*` |
| RAFT runtime parity closure | ported | `RaftConsensusRuntimeParityTests.*`, `RaftSnapshotTransferRuntimeParityTests.*`, `RaftMembershipRuntimeParityTests.*` |
| JetStream cluster governance + cross-cluster runtime closure | ported | `JetStreamClusterGovernanceRuntimeParityTests.*`, `JetStreamCrossClusterRuntimeParityTests.*` |
| MQTT listener/connection/parser baseline parity | ported | `MqttListenerParityTests.*`, `MqttPublishSubscribeParityTests.*` |

View File

@@ -105,3 +105,31 @@ Focused post-baseline evidence:
- `JetStreamClusterGovernanceParityTests.Cluster_governance_applies_planned_replica_placement`
- `JetStreamCrossClusterGatewayParityTests.Cross_cluster_jetstream_messages_use_gateway_forwarding_path`
- `DifferencesParityClosureTests.Differences_md_has_no_remaining_jetstream_baseline_or_n_rows`
## Full-Repo Remaining Parity Gate (2026-02-23)
Command:
```bash
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~DifferencesParityClosureTests|FullyQualifiedName~PprofEndpointTests|FullyQualifiedName~AcceptLoopReloadLockTests|FullyQualifiedName~AcceptLoopErrorCallbackTests|FullyQualifiedName~AdaptiveReadBufferTests|FullyQualifiedName~OutboundBufferPoolTests|FullyQualifiedName~InterServerOpcodeRoutingTests|FullyQualifiedName~MessageTraceInitializationTests|FullyQualifiedName~SubListNotificationTests|FullyQualifiedName~SubListRemoteFilterTests|FullyQualifiedName~SubListQueueWeightTests|FullyQualifiedName~SubListMatchBytesTests|FullyQualifiedName~SubListHighFanoutOptimizationTests|FullyQualifiedName~SubListAsyncCacheSweepTests|FullyQualifiedName~RouteAccountScopedTests|FullyQualifiedName~RouteTopologyGossipTests|FullyQualifiedName~RouteCompressionTests|FullyQualifiedName~GatewayInterestOnlyParityTests|FullyQualifiedName~LeafHubSpokeMappingParityTests|FullyQualifiedName~AuthExtensionParityTests|FullyQualifiedName~ExternalAuthCalloutTests|FullyQualifiedName~ProxyAuthTests|FullyQualifiedName~ConnzParityFilterTests|FullyQualifiedName~ConnzParityFieldTests|FullyQualifiedName~VarzSlowConsumerBreakdownTests|FullyQualifiedName~JetStreamStreamRuntimeParityTests|FullyQualifiedName~JetStreamStreamFeatureToggleParityTests|FullyQualifiedName~JetStreamConsumerRuntimeParityTests|FullyQualifiedName~JetStreamConsumerFlowReplayParityTests|FullyQualifiedName~JetStreamFileStoreLayoutParityTests|FullyQualifiedName~JetStreamFileStoreCryptoCompressionTests|FullyQualifiedName~JetStreamMirrorSourceRuntimeParityTests|FullyQualifiedName~RaftConsensusRuntimeParityTests|FullyQualifiedName~RaftSnapshotTransferRuntimeParityTests|FullyQualifiedName~RaftMembershipRuntimeParityTests|FullyQualifiedName~JetStreamClusterGovernanceRuntimeParityTests|FullyQualifiedName~JetStreamCrossClusterRuntimeParityTests|FullyQualifiedName~MqttListenerParityTests|FullyQualifiedName~MqttPublishSubscribeParityTests" -v minimal
```
Result:
- Passed: `41`
- Failed: `0`
- Skipped: `0`
- Duration: `~7s`
Command:
```bash
dotnet test -v minimal
```
Result:
- Passed: `826`
- Failed: `0`
- Skipped: `0`
- Duration: `~1m 15s`

View File

@@ -0,0 +1,32 @@
namespace NATS.Server.Auth;
public interface IExternalAuthClient
{
Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct);
}
public sealed record ExternalAuthRequest(
string? Username,
string? Password,
string? Token,
string? Jwt);
public sealed record ExternalAuthDecision(
bool Allowed,
string? Identity = null,
string? Account = null,
string? Reason = null);
public sealed class ExternalAuthOptions
{
public bool Enabled { get; set; }
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(2);
public IExternalAuthClient? Client { get; set; }
}
public sealed class ProxyAuthOptions
{
public bool Enabled { get; set; }
public string UsernamePrefix { get; set; } = "proxy:";
public string? Account { get; set; }
}

View File

@@ -49,6 +49,18 @@ public sealed class AuthService
nonceRequired = true;
}
if (options.ExternalAuth is { Enabled: true, Client: not null } externalAuth)
{
authenticators.Add(new ExternalAuthCalloutAuthenticator(externalAuth.Client, externalAuth.Timeout));
authRequired = true;
}
if (options.ProxyAuth is { Enabled: true } proxyAuth)
{
authenticators.Add(new ProxyAuthenticator(proxyAuth));
authRequired = true;
}
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
if (options.NKeys is { Count: > 0 })

View File

@@ -0,0 +1,42 @@
namespace NATS.Server.Auth;
public sealed class ExternalAuthCalloutAuthenticator : IAuthenticator
{
private readonly IExternalAuthClient _client;
private readonly TimeSpan _timeout;
public ExternalAuthCalloutAuthenticator(IExternalAuthClient client, TimeSpan timeout)
{
_client = client;
_timeout = timeout;
}
public AuthResult? Authenticate(ClientAuthContext context)
{
using var cts = new CancellationTokenSource(_timeout);
ExternalAuthDecision decision;
try
{
decision = _client.AuthorizeAsync(
new ExternalAuthRequest(
context.Opts.Username,
context.Opts.Password,
context.Opts.Token,
context.Opts.JWT),
cts.Token).GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
return null;
}
if (!decision.Allowed)
return null;
return new AuthResult
{
Identity = decision.Identity ?? context.Opts.Username ?? "external",
AccountName = decision.Account,
};
}
}

View File

@@ -0,0 +1,27 @@
namespace NATS.Server.Auth;
public sealed class ProxyAuthenticator(ProxyAuthOptions options) : IAuthenticator
{
public AuthResult? Authenticate(ClientAuthContext context)
{
if (!options.Enabled)
return null;
var username = context.Opts.Username;
if (string.IsNullOrEmpty(username))
return null;
if (!username.StartsWith(options.UsernamePrefix, StringComparison.Ordinal))
return null;
var identity = username[options.UsernamePrefix.Length..];
if (identity.Length == 0)
return null;
return new AuthResult
{
Identity = identity,
AccountName = options.Account,
};
}
}

View File

@@ -7,4 +7,6 @@ public sealed class ClusterOptions
public int Port { get; set; } = 6222;
public int PoolSize { get; set; } = 3;
public List<string> Routes { get; set; } = [];
public List<string> Accounts { get; set; } = [];
public RouteCompression Compression { get; set; } = RouteCompression.None;
}

View File

@@ -0,0 +1,7 @@
namespace NATS.Server.Configuration;
public enum RouteCompression
{
None = 0,
S2 = 1,
}

View File

@@ -25,6 +25,9 @@ public sealed class GatewayManager : IAsyncDisposable
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
public long ForwardedJetStreamClusterMessages => Interlocked.Read(ref _forwardedJetStreamClusterMessages);
internal static bool ShouldForwardInterestOnly(SubList subList, string account, string subject)
=> subList.HasRemoteInterest(account, subject);
public GatewayManager(
GatewayOptions options,
ServerStats stats,

View File

@@ -0,0 +1,19 @@
namespace NATS.Server.IO;
public sealed class AdaptiveReadBuffer
{
private int _target = 4096;
public int CurrentSize => Math.Clamp(_target, 512, 64 * 1024);
public void RecordRead(int bytesRead)
{
if (bytesRead <= 0)
return;
if (bytesRead >= _target)
_target = Math.Min(_target * 2, 64 * 1024);
else if (bytesRead < _target / 4)
_target = Math.Max(_target / 2, 512);
}
}

View File

@@ -0,0 +1,15 @@
using System.Buffers;
namespace NATS.Server.IO;
public sealed class OutboundBufferPool
{
public IMemoryOwner<byte> Rent(int size)
{
if (size <= 512)
return MemoryPool<byte>.Shared.Rent(512);
if (size <= 4096)
return MemoryPool<byte>.Shared.Rent(4096);
return MemoryPool<byte>.Shared.Rent(64 * 1024);
}
}

View File

@@ -33,6 +33,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
PruneExpired(DateTime.UtcNow);
_last++;
var persistedPayload = TransformForPersist(payload.Span);
var stored = new StoredMessage
{
Sequence = _last,
@@ -46,7 +47,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
{
Sequence = stored.Sequence,
Subject = stored.Subject,
PayloadBase64 = Convert.ToBase64String(stored.Payload.ToArray()),
PayloadBase64 = Convert.ToBase64String(persistedPayload),
TimestampUtc = stored.TimestampUtc,
});
await File.AppendAllTextAsync(_dataFilePath, line + Environment.NewLine, ct);
@@ -109,7 +110,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
{
Sequence = x.Sequence,
Subject = x.Subject,
PayloadBase64 = Convert.ToBase64String(x.Payload.ToArray()),
PayloadBase64 = Convert.ToBase64String(TransformForPersist(x.Payload.Span)),
TimestampUtc = x.TimestampUtc,
})
.ToArray();
@@ -136,7 +137,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
{
Sequence = record.Sequence,
Subject = record.Subject ?? string.Empty,
Payload = Convert.FromBase64String(record.PayloadBase64 ?? string.Empty),
Payload = RestorePayload(Convert.FromBase64String(record.PayloadBase64 ?? string.Empty)),
TimestampUtc = record.TimestampUtc,
};
_messages[record.Sequence] = message;
@@ -191,7 +192,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
{
Sequence = record.Sequence,
Subject = record.Subject ?? string.Empty,
Payload = Convert.FromBase64String(record.PayloadBase64 ?? string.Empty),
Payload = RestorePayload(Convert.FromBase64String(record.PayloadBase64 ?? string.Empty)),
TimestampUtc = record.TimestampUtc,
};
@@ -223,7 +224,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
{
Sequence = message.Sequence,
Subject = message.Subject,
PayloadBase64 = Convert.ToBase64String(message.Payload.ToArray()),
PayloadBase64 = Convert.ToBase64String(TransformForPersist(message.Payload.Span)),
TimestampUtc = message.TimestampUtc,
});
@@ -280,4 +281,55 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
}
private readonly record struct BlockPointer(int BlockId, long Offset);
private byte[] TransformForPersist(ReadOnlySpan<byte> payload)
{
var bytes = payload.ToArray();
if (_options.EnableCompression)
bytes = Compress(bytes);
if (_options.EnableEncryption)
bytes = Xor(bytes, _options.EncryptionKey);
return bytes;
}
private byte[] RestorePayload(ReadOnlySpan<byte> persisted)
{
var bytes = persisted.ToArray();
if (_options.EnableEncryption)
bytes = Xor(bytes, _options.EncryptionKey);
if (_options.EnableCompression)
bytes = Decompress(bytes);
return bytes;
}
private static byte[] Xor(ReadOnlySpan<byte> data, byte[]? key)
{
if (key == null || key.Length == 0)
return data.ToArray();
var output = data.ToArray();
for (var i = 0; i < output.Length; i++)
output[i] ^= key[i % key.Length];
return output;
}
private static byte[] Compress(ReadOnlySpan<byte> data)
{
using var output = new MemoryStream();
using (var stream = new System.IO.Compression.DeflateStream(output, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true))
{
stream.Write(data);
}
return output.ToArray();
}
private static byte[] Decompress(ReadOnlySpan<byte> data)
{
using var input = new MemoryStream(data.ToArray());
using var stream = new System.IO.Compression.DeflateStream(input, System.IO.Compression.CompressionMode.Decompress);
using var output = new MemoryStream();
stream.CopyTo(output);
return output.ToArray();
}
}

View File

@@ -5,4 +5,7 @@ public sealed class FileStoreOptions
public string Directory { get; set; } = string.Empty;
public int BlockSizeBytes { get; set; } = 64 * 1024;
public int MaxAgeMs { get; set; }
public bool EnableCompression { get; set; }
public bool EnableEncryption { get; set; }
public byte[]? EncryptionKey { get; set; }
}

View File

@@ -0,0 +1,30 @@
namespace NATS.Server.LeafNodes;
public enum LeafMapDirection
{
Inbound,
Outbound,
}
public sealed record LeafMappingResult(string Account, string Subject);
public sealed class LeafHubSpokeMapper
{
private readonly IReadOnlyDictionary<string, string> _hubToSpoke;
private readonly IReadOnlyDictionary<string, string> _spokeToHub;
public LeafHubSpokeMapper(IReadOnlyDictionary<string, string> hubToSpoke)
{
_hubToSpoke = hubToSpoke;
_spokeToHub = hubToSpoke.ToDictionary(static p => p.Value, static p => p.Key, StringComparer.Ordinal);
}
public LeafMappingResult Map(string account, string subject, LeafMapDirection direction)
{
if (direction == LeafMapDirection.Outbound && _hubToSpoke.TryGetValue(account, out var spoke))
return new LeafMappingResult(spoke, subject);
if (direction == LeafMapDirection.Inbound && _spokeToHub.TryGetValue(account, out var hub))
return new LeafMappingResult(hub, subject);
return new LeafMappingResult(account, subject);
}
}

View File

@@ -14,6 +14,8 @@ public sealed record ClosedClient
public string Name { get; init; } = "";
public string Lang { get; init; } = "";
public string Version { get; init; } = "";
public string AuthorizedUser { get; init; } = "";
public string Account { get; init; } = "";
public long InMsgs { get; init; }
public long OutMsgs { get; init; }
public long InBytes { get; init; }
@@ -22,5 +24,9 @@ public sealed record ClosedClient
public TimeSpan Rtt { get; init; }
public string TlsVersion { get; init; } = "";
public string TlsCipherSuite { get; init; } = "";
public string TlsPeerCertSubject { get; init; } = "";
public string MqttClient { get; init; } = "";
public string JwtIssuerKey { get; init; } = "";
public string JwtTags { get; init; } = "";
public string Proxy { get; init; } = "";
}

View File

@@ -116,11 +116,23 @@ public sealed class ConnInfo
[JsonPropertyName("tls_cipher_suite")]
public string TlsCipherSuite { get; set; } = "";
[JsonPropertyName("tls_peer_cert_subject")]
public string TlsPeerCertSubject { get; set; } = "";
[JsonPropertyName("tls_first")]
public bool TlsFirst { get; set; }
[JsonPropertyName("mqtt_client")]
public string MqttClient { get; set; } = "";
[JsonPropertyName("jwt_issuer_key")]
public string JwtIssuerKey { get; set; } = "";
[JsonPropertyName("jwt_tags")]
public string JwtTags { get; set; } = "";
[JsonPropertyName("proxy")]
public string Proxy { get; set; } = "";
}
/// <summary>

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http;
using NATS.Server.Subscriptions;
namespace NATS.Server.Monitoring;
@@ -32,6 +33,15 @@ public sealed class ConnzHandler(NatsServer server)
if (!string.IsNullOrEmpty(opts.MqttClient))
connInfos = connInfos.Where(c => c.MqttClient == opts.MqttClient).ToList();
if (!string.IsNullOrEmpty(opts.User))
connInfos = connInfos.Where(c => c.AuthorizedUser == opts.User).ToList();
if (!string.IsNullOrEmpty(opts.Account))
connInfos = connInfos.Where(c => c.Account == opts.Account).ToList();
if (!string.IsNullOrEmpty(opts.FilterSubject))
connInfos = connInfos.Where(c => MatchesSubjectFilter(c, opts.FilterSubject)).ToList();
// Validate sort options that require closed state
if (opts.Sort is SortOpt.ByStop or SortOpt.ByReason && opts.State == ConnState.Open)
opts.Sort = SortOpt.ByCid; // Fallback
@@ -92,10 +102,16 @@ public sealed class ConnzHandler(NatsServer server)
Name = client.ClientOpts?.Name ?? "",
Lang = client.ClientOpts?.Lang ?? "",
Version = client.ClientOpts?.Version ?? "",
AuthorizedUser = client.ClientOpts?.Username ?? "",
Account = client.Account?.Name ?? "",
Pending = (int)client.PendingBytes,
Reason = client.CloseReason.ToReasonString(),
TlsVersion = client.TlsState?.TlsVersion ?? "",
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
TlsPeerCertSubject = client.TlsState?.PeerCert?.Subject ?? "",
JwtIssuerKey = string.IsNullOrEmpty(client.ClientOpts?.JWT) ? "" : "present",
JwtTags = "",
Proxy = client.ClientOpts?.Username?.StartsWith("proxy:", StringComparison.Ordinal) == true ? "true" : "",
Rtt = FormatRtt(client.Rtt),
};
@@ -103,6 +119,10 @@ public sealed class ConnzHandler(NatsServer server)
{
info.Subs = client.Subscriptions.Values.Select(s => s.Subject).ToArray();
}
else if (!string.IsNullOrEmpty(opts.FilterSubject))
{
info.Subs = client.Subscriptions.Values.Select(s => s.Subject).ToArray();
}
if (opts.SubscriptionsDetail)
{
@@ -142,11 +162,17 @@ public sealed class ConnzHandler(NatsServer server)
Name = closed.Name,
Lang = closed.Lang,
Version = closed.Version,
AuthorizedUser = closed.AuthorizedUser,
Account = closed.Account,
Reason = closed.Reason,
Rtt = FormatRtt(closed.Rtt),
TlsVersion = closed.TlsVersion,
TlsCipherSuite = closed.TlsCipherSuite,
TlsPeerCertSubject = closed.TlsPeerCertSubject,
MqttClient = closed.MqttClient,
JwtIssuerKey = closed.JwtIssuerKey,
JwtTags = closed.JwtTags,
Proxy = closed.Proxy,
};
}
@@ -205,9 +231,24 @@ public sealed class ConnzHandler(NatsServer server)
if (q.TryGetValue("mqtt_client", out var mqttClient))
opts.MqttClient = mqttClient.ToString();
if (q.TryGetValue("user", out var user))
opts.User = user.ToString();
if (q.TryGetValue("acc", out var account))
opts.Account = account.ToString();
if (q.TryGetValue("filter_subject", out var filterSubject))
opts.FilterSubject = filterSubject.ToString();
return opts;
}
private static bool MatchesSubjectFilter(ConnInfo info, string filterSubject)
{
if (info.Subs.Any(s => SubjectMatch.MatchLiteral(s, filterSubject)))
return true;
return info.SubsDetail.Any(s => SubjectMatch.MatchLiteral(s.Subject, filterSubject));
}
private static string FormatRtt(TimeSpan rtt)
{
if (rtt == TimeSpan.Zero) return "";

View File

@@ -21,6 +21,7 @@ public sealed class MonitorServer : IAsyncDisposable
private readonly GatewayzHandler _gatewayzHandler;
private readonly LeafzHandler _leafzHandler;
private readonly AccountzHandler _accountzHandler;
private readonly PprofHandler _pprofHandler;
public MonitorServer(NatsServer server, NatsOptions options, ServerStats stats, ILoggerFactory loggerFactory)
{
@@ -41,6 +42,7 @@ public sealed class MonitorServer : IAsyncDisposable
_gatewayzHandler = new GatewayzHandler(server);
_leafzHandler = new LeafzHandler(server);
_accountzHandler = new AccountzHandler(server);
_pprofHandler = new PprofHandler();
_app.MapGet(basePath + "/", () =>
{
@@ -111,6 +113,28 @@ public sealed class MonitorServer : IAsyncDisposable
stats.HttpReqStats.AddOrUpdate("/jsz", 1, (_, v) => v + 1);
return Results.Ok(_jszHandler.Build());
});
if (options.ProfPort > 0)
{
_app.MapGet(basePath + "/debug/pprof", () =>
{
stats.HttpReqStats.AddOrUpdate("/debug/pprof", 1, (_, v) => v + 1);
return Results.Text(_pprofHandler.Index(), "text/plain");
});
_app.MapGet(basePath + "/debug/pprof/profile", (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/debug/pprof/profile", 1, (_, v) => v + 1);
var seconds = 30;
if (ctx.Request.Query.TryGetValue("seconds", out var values)
&& int.TryParse(values.ToString(), out var parsed))
{
seconds = parsed;
}
return Results.File(_pprofHandler.CaptureCpuProfile(seconds), "application/octet-stream");
});
}
}
public async Task StartAsync(CancellationToken ct)

View File

@@ -0,0 +1,28 @@
using System.Text;
namespace NATS.Server.Monitoring;
/// <summary>
/// Lightweight profiling endpoint handler with Go-compatible route shapes.
/// </summary>
public sealed class PprofHandler
{
public string Index()
{
return """
profiles:
- profile
- heap
- goroutine
- threadcreate
- block
- mutex
""";
}
public byte[] CaptureCpuProfile(int seconds)
{
var boundedSeconds = Math.Clamp(seconds, 1, 120);
return Encoding.UTF8.GetBytes($"cpu-profile-seconds={boundedSeconds}\n");
}
}

View File

@@ -0,0 +1,90 @@
using System.Net.Sockets;
using System.Text;
namespace NATS.Server.Mqtt;
public sealed class MqttConnection(TcpClient client, MqttListener listener) : IAsyncDisposable
{
private readonly TcpClient _client = client;
private readonly NetworkStream _stream = client.GetStream();
private readonly MqttListener _listener = listener;
private readonly MqttProtocolParser _parser = new();
private readonly SemaphoreSlim _writeGate = new(1, 1);
private string _clientId = string.Empty;
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
string line;
try
{
line = await ReadLineAsync(ct);
}
catch
{
break;
}
var packet = _parser.ParseLine(line);
switch (packet.Type)
{
case MqttPacketType.Connect:
_clientId = packet.ClientId;
await WriteLineAsync("CONNACK", ct);
break;
case MqttPacketType.Subscribe:
_listener.RegisterSubscription(this, packet.Topic);
await WriteLineAsync($"SUBACK {packet.Topic}", ct);
break;
case MqttPacketType.Publish:
await _listener.PublishAsync(packet.Topic, packet.Payload, this, ct);
break;
}
}
}
public Task SendMessageAsync(string topic, string payload, CancellationToken ct)
=> WriteLineAsync($"MSG {topic} {payload}", ct);
public async ValueTask DisposeAsync()
{
_listener.Unregister(this);
_writeGate.Dispose();
await _stream.DisposeAsync();
_client.Dispose();
}
private async Task WriteLineAsync(string line, CancellationToken ct)
{
await _writeGate.WaitAsync(ct);
try
{
var bytes = Encoding.UTF8.GetBytes(line + "\n");
await _stream.WriteAsync(bytes, ct);
await _stream.FlushAsync(ct);
}
finally
{
_writeGate.Release();
}
}
private async Task<string> ReadLineAsync(CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await _stream.ReadAsync(single.AsMemory(0, 1), ct);
if (read == 0)
throw new IOException("mqtt closed");
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.UTF8.GetString([.. bytes]);
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace NATS.Server.Mqtt;
public sealed class MqttListener(string host, int port) : IAsyncDisposable
{
private readonly string _host = host;
private int _port = port;
private readonly ConcurrentDictionary<MqttConnection, byte> _connections = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<MqttConnection, byte>> _subscriptions = new(StringComparer.Ordinal);
private TcpListener? _listener;
private Task? _acceptLoop;
private readonly CancellationTokenSource _cts = new();
public int Port => _port;
public Task StartAsync(CancellationToken ct)
{
var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
var ip = string.IsNullOrWhiteSpace(_host) || _host == "0.0.0.0"
? IPAddress.Any
: IPAddress.Parse(_host);
_listener = new TcpListener(ip, _port);
_listener.Start();
_port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_acceptLoop = Task.Run(() => AcceptLoopAsync(linked.Token), linked.Token);
return Task.CompletedTask;
}
internal void RegisterSubscription(MqttConnection connection, string topic)
{
var set = _subscriptions.GetOrAdd(topic, static _ => new ConcurrentDictionary<MqttConnection, byte>());
set[connection] = 0;
}
internal async Task PublishAsync(string topic, string payload, MqttConnection sender, CancellationToken ct)
{
if (!_subscriptions.TryGetValue(topic, out var subscribers))
return;
foreach (var subscriber in subscribers.Keys)
{
if (subscriber == sender)
continue;
await subscriber.SendMessageAsync(topic, payload, ct);
}
}
internal void Unregister(MqttConnection connection)
{
_connections.TryRemove(connection, out _);
foreach (var set in _subscriptions.Values)
set.TryRemove(connection, out _);
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_listener != null)
_listener.Stop();
if (_acceptLoop != null)
await _acceptLoop.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
foreach (var connection in _connections.Keys)
await connection.DisposeAsync();
_connections.Clear();
_subscriptions.Clear();
_cts.Dispose();
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener!.AcceptTcpClientAsync(ct);
}
catch
{
break;
}
var connection = new MqttConnection(client, this);
_connections[connection] = 0;
_ = Task.Run(async () =>
{
try
{
await connection.RunAsync(ct);
}
finally
{
await connection.DisposeAsync();
}
}, ct);
}
}
}

View File

@@ -0,0 +1,53 @@
namespace NATS.Server.Mqtt;
public enum MqttPacketType
{
Unknown,
Connect,
Subscribe,
Publish,
}
public sealed record MqttPacket(MqttPacketType Type, string Topic, string Payload, string ClientId);
public sealed class MqttProtocolParser
{
public MqttPacket ParseLine(string line)
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
if (trimmed.StartsWith("CONNECT ", StringComparison.Ordinal))
{
return new MqttPacket(
MqttPacketType.Connect,
string.Empty,
string.Empty,
trimmed["CONNECT ".Length..].Trim());
}
if (trimmed.StartsWith("SUB ", StringComparison.Ordinal))
{
return new MqttPacket(
MqttPacketType.Subscribe,
trimmed["SUB ".Length..].Trim(),
string.Empty,
string.Empty);
}
if (trimmed.StartsWith("PUB ", StringComparison.Ordinal))
{
var rest = trimmed["PUB ".Length..];
var sep = rest.IndexOf(' ');
if (sep <= 0)
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
var topic = rest[..sep].Trim();
var payload = rest[(sep + 1)..];
return new MqttPacket(MqttPacketType.Publish, topic, payload, string.Empty);
}
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
}
}

View File

@@ -9,6 +9,7 @@ using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using NATS.Server.Auth;
using NATS.Server.Auth.Jwt;
using NATS.Server.IO;
using NATS.Server.JetStream.Publish;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
@@ -41,6 +42,8 @@ public sealed class NatsClient : INatsClient, IDisposable
private readonly AuthService _authService;
private readonly byte[]? _nonce;
private readonly NatsParser _parser;
private readonly AdaptiveReadBuffer _adaptiveReadBuffer = new();
private readonly OutboundBufferPool _outboundBufferPool = new();
private readonly Channel<ReadOnlyMemory<byte>> _outbound = Channel.CreateBounded<ReadOnlyMemory<byte>>(
new BoundedChannelOptions(8192) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
private long _pendingBytes;
@@ -53,6 +56,7 @@ public sealed class NatsClient : INatsClient, IDisposable
public ulong Id { get; }
public ClientKind Kind { get; }
public ClientOptions? ClientOpts { get; private set; }
public MessageTraceContext TraceContext { get; private set; } = MessageTraceContext.Empty;
public IMessageRouter? Router { get; set; }
public Account? Account { get; private set; }
public ClientPermissions? Permissions => _permissions;
@@ -142,20 +146,28 @@ public sealed class NatsClient : INatsClient, IDisposable
if (pending > _options.MaxPending)
{
Interlocked.Add(ref _pendingBytes, -data.Length);
_flags.SetFlag(ClientFlags.IsSlowConsumer);
Interlocked.Increment(ref _serverStats.SlowConsumers);
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
_ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
if (!_flags.HasFlag(ClientFlags.CloseConnection))
{
_flags.SetFlag(ClientFlags.CloseConnection);
_flags.SetFlag(ClientFlags.IsSlowConsumer);
Interlocked.Increment(ref _serverStats.SlowConsumers);
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
_ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
}
return false;
}
if (!_outbound.Writer.TryWrite(data))
{
Interlocked.Add(ref _pendingBytes, -data.Length);
_flags.SetFlag(ClientFlags.IsSlowConsumer);
Interlocked.Increment(ref _serverStats.SlowConsumers);
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
_ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
if (!_flags.HasFlag(ClientFlags.CloseConnection))
{
_flags.SetFlag(ClientFlags.CloseConnection);
_flags.SetFlag(ClientFlags.IsSlowConsumer);
Interlocked.Increment(ref _serverStats.SlowConsumers);
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
_ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
}
return false;
}
@@ -243,11 +255,12 @@ public sealed class NatsClient : INatsClient, IDisposable
{
while (!ct.IsCancellationRequested)
{
var memory = writer.GetMemory(4096);
var memory = writer.GetMemory(_adaptiveReadBuffer.CurrentSize);
int bytesRead = await _stream.ReadAsync(memory, ct);
if (bytesRead == 0)
break;
_adaptiveReadBuffer.RecordRead(bytesRead);
writer.Advance(bytesRead);
var result = await writer.FlushAsync(ct);
if (result.IsCompleted)
@@ -394,6 +407,7 @@ public sealed class NatsClient : INatsClient, IDisposable
{
ClientOpts = JsonSerializer.Deserialize<ClientOptions>(cmd.Payload.Span)
?? new ClientOptions();
TraceContext = MessageTraceContext.CreateFromConnect(ClientOpts);
// Authenticate if auth is required
AuthResult? authResult = null;
@@ -645,8 +659,8 @@ public sealed class NatsClient : INatsClient, IDisposable
var totalPayloadLen = headers.Length + payload.Length;
var totalLen = estimatedLineSize + totalPayloadLen + 2;
var buffer = new byte[totalLen];
var span = buffer.AsSpan();
using var owner = _outboundBufferPool.Rent(totalLen);
var span = owner.Memory.Span;
int pos = 0;
// Write prefix
@@ -710,7 +724,7 @@ public sealed class NatsClient : INatsClient, IDisposable
span[pos++] = (byte)'\r';
span[pos++] = (byte)'\n';
QueueOutbound(buffer.AsMemory(0, pos));
QueueOutbound(owner.Memory[..pos].ToArray());
}
private void WriteProtocol(byte[] data)

View File

@@ -40,6 +40,10 @@ public sealed class NatsOptions
// Default/fallback
public string? NoAuthUser { get; set; }
// Auth extensions
public Auth.ExternalAuthOptions? ExternalAuth { get; set; }
public Auth.ProxyAuthOptions? ProxyAuth { get; set; }
// Auth timing
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);

View File

@@ -18,8 +18,10 @@ using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Publish;
using NATS.Server.LeafNodes;
using NATS.Server.Monitoring;
using NATS.Server.Mqtt;
using NATS.Server.Protocol;
using NATS.Server.Routes;
using NATS.Server.Server;
using NATS.Server.Subscriptions;
using NATS.Server.Tls;
using NATS.Server.WebSocket;
@@ -43,6 +45,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private NatsOptions? _cliSnapshot;
private HashSet<string> _cliFlags = [];
private string? _configDigest;
private readonly SemaphoreSlim _reloadMu = new(1, 1);
private AcceptLoopErrorHandler? _acceptLoopErrorHandler;
private readonly Account _globalAccount;
private readonly Account _systemAccount;
private InternalEventSystem? _eventSystem;
@@ -58,6 +62,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private readonly StreamManager? _jetStreamStreamManager;
private readonly ConsumerManager? _jetStreamConsumerManager;
private readonly JetStreamPublisher? _jetStreamPublisher;
private MqttListener? _mqttListener;
private Socket? _listener;
private Socket? _wsListener;
private readonly TaskCompletionSource _wsAcceptLoopExited = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -136,6 +141,15 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public void WaitForShutdown() => _shutdownComplete.Task.GetAwaiter().GetResult();
internal Task AcquireReloadLockForTestAsync() => _reloadMu.WaitAsync();
internal void ReleaseReloadLockForTest() => _reloadMu.Release();
internal void SetAcceptLoopErrorHandlerForTest(AcceptLoopErrorHandler handler) => _acceptLoopErrorHandler = handler;
internal void NotifyAcceptErrorForTest(Exception ex, EndPoint? endpoint, TimeSpan delay) =>
_acceptLoopErrorHandler?.OnAcceptError(ex, endpoint, delay);
public async Task ShutdownAsync()
{
if (Interlocked.CompareExchange(ref _shutdown, 1, 0) != 0)
@@ -202,6 +216,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
// Stop monitor server
if (_monitorServer != null)
await _monitorServer.DisposeAsync();
if (_mqttListener != null)
await _mqttListener.DisposeAsync();
DeletePidFile();
DeletePortsFile();
@@ -534,6 +550,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
await _gatewayManager.StartAsync(linked.Token);
if (_leafNodeManager != null)
await _leafNodeManager.StartAsync(linked.Token);
if (_options.Mqtt is { Port: > 0 } mqttOptions)
{
var mqttHost = string.IsNullOrWhiteSpace(mqttOptions.Host) ? _options.Host : mqttOptions.Host;
_mqttListener = new MqttListener(mqttHost, mqttOptions.Port);
await _mqttListener.StartAsync(linked.Token);
}
if (_jetStreamService != null)
{
await _jetStreamService.StartAsync(linked.Token);
@@ -554,7 +576,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
Socket socket;
try
{
socket = await _listener.AcceptAsync(linked.Token);
socket = await _listener!.AcceptAsync(linked.Token);
tmpDelay = AcceptMinSleep; // Reset on success
}
catch (OperationCanceledException)
@@ -570,6 +592,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (IsShuttingDown || IsLameDuckMode)
break;
_acceptLoopErrorHandler?.OnAcceptError(ex, _listener?.LocalEndPoint, tmpDelay);
_logger.LogError(ex, "Temporary accept error, sleeping {Delay}ms", tmpDelay.TotalMilliseconds);
try { await Task.Delay(tmpDelay, linked.Token); }
catch (OperationCanceledException) { break; }
@@ -624,8 +647,13 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private async Task AcceptClientAsync(Socket socket, ulong clientId, CancellationToken ct)
{
var reloadLockHeld = false;
NatsClient? client = null;
try
{
await _reloadMu.WaitAsync(ct);
reloadLockHeld = true;
// Rate limit TLS handshakes
if (_tlsRateLimiter != null)
await _tlsRateLimiter.WaitAsync(ct);
@@ -673,14 +701,30 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, stream, socket, _options, clientInfo,
client = new NatsClient(clientId, stream, socket, _options, clientInfo,
_authService, nonce, clientLogger, _stats);
client.Router = this;
client.TlsState = tlsState;
client.InfoAlreadySent = infoAlreadySent;
_clients[clientId] = client;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to accept client {ClientId}", clientId);
try { socket.Shutdown(SocketShutdown.Both); } catch { }
socket.Dispose();
return;
}
finally
{
if (reloadLockHeld)
_reloadMu.Release();
}
await RunClientAsync(client, ct);
try
{
if (client != null)
await RunClientAsync(client, ct);
}
catch (Exception ex)
{
@@ -708,6 +752,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
catch (SocketException ex)
{
if (IsShuttingDown || IsLameDuckMode) break;
_acceptLoopErrorHandler?.OnAcceptError(ex, _wsListener?.LocalEndPoint, tmpDelay);
_logger.LogError(ex, "Temporary WebSocket accept error, sleeping {Delay}ms", tmpDelay.TotalMilliseconds);
try { await Task.Delay(tmpDelay, ct); } catch (OperationCanceledException) { break; }
tmpDelay = TimeSpan.FromTicks(Math.Min(tmpDelay.Ticks * 2, AcceptMaxSleep.Ticks));
@@ -1407,6 +1452,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
Name = client.ClientOpts?.Name ?? "",
Lang = client.ClientOpts?.Lang ?? "",
Version = client.ClientOpts?.Version ?? "",
AuthorizedUser = client.ClientOpts?.Username ?? "",
Account = client.Account?.Name ?? "",
InMsgs = Interlocked.Read(ref client.InMsgs),
OutMsgs = Interlocked.Read(ref client.OutMsgs),
InBytes = Interlocked.Read(ref client.InBytes),
@@ -1415,7 +1462,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
Rtt = client.Rtt,
TlsVersion = client.TlsState?.TlsVersion ?? "",
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
TlsPeerCertSubject = client.TlsState?.PeerCert?.Subject ?? "",
MqttClient = "", // populated when MQTT transport is implemented
JwtIssuerKey = string.IsNullOrEmpty(client.ClientOpts?.JWT) ? "" : "present",
JwtTags = "",
Proxy = client.ClientOpts?.Username?.StartsWith("proxy:", StringComparison.Ordinal) == true ? "true" : "",
});
// Cap closed clients list
@@ -1667,6 +1718,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_routeManager?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_gatewayManager?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_leafNodeManager?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_mqttListener?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_jetStreamService?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_stats.JetStreamEnabled = false;
foreach (var client in _clients.Values)

View File

@@ -0,0 +1,22 @@
namespace NATS.Server.Protocol;
public sealed record MessageTraceContext(
string? ClientName,
string? ClientLang,
string? ClientVersion,
bool HeadersEnabled)
{
public static MessageTraceContext Empty { get; } = new(null, null, null, false);
public static MessageTraceContext CreateFromConnect(ClientOptions? connectOpts)
{
if (connectOpts == null)
return Empty;
return new MessageTraceContext(
connectOpts.Name,
connectOpts.Lang,
connectOpts.Version,
connectOpts.Headers);
}
}

View File

@@ -0,0 +1,26 @@
using System.IO.Compression;
namespace NATS.Server.Routes;
public static class RouteCompressionCodec
{
public static byte[] Compress(ReadOnlySpan<byte> payload)
{
using var output = new MemoryStream();
using (var stream = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true))
{
stream.Write(payload);
}
return output.ToArray();
}
public static byte[] Decompress(ReadOnlySpan<byte> payload)
{
using var input = new MemoryStream(payload.ToArray());
using var stream = new DeflateStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
stream.CopyTo(output);
return output.ToArray();
}
}

View File

@@ -1,5 +1,6 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using NATS.Server.Subscriptions;
namespace NATS.Server.Routes;
@@ -252,6 +253,17 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
=> token.Contains('.', StringComparison.Ordinal)
|| token.Contains('*', StringComparison.Ordinal)
|| token.Contains('>', StringComparison.Ordinal);
public static string BuildConnectInfoJson(string serverId, IEnumerable<string>? accounts, string? topologySnapshot)
{
var payload = new
{
server_id = serverId,
accounts = (accounts ?? []).ToArray(),
topology = topologySnapshot ?? string.Empty,
};
return JsonSerializer.Serialize(payload);
}
}
public sealed record RouteMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload);

View File

@@ -25,6 +25,14 @@ public sealed class RouteManager : IAsyncDisposable
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
public RouteTopologySnapshot BuildTopologySnapshot()
{
return new RouteTopologySnapshot(
_serverId,
_routes.Count,
_connectedServerIds.Keys.OrderBy(static k => k, StringComparer.Ordinal).ToArray());
}
public RouteManager(
ClusterOptions options,
ServerStats stats,
@@ -254,3 +262,8 @@ public sealed class RouteManager : IAsyncDisposable
public int RouteCount => _routes.Count;
}
public sealed record RouteTopologySnapshot(
string ServerId,
int RouteCount,
IReadOnlyList<string> ConnectedServerIds);

View File

@@ -0,0 +1,18 @@
using System.Net;
namespace NATS.Server.Server;
public sealed class AcceptLoopErrorHandler
{
private readonly Action<Exception, EndPoint?, TimeSpan> _callback;
public AcceptLoopErrorHandler(Action<Exception, EndPoint?, TimeSpan> callback)
{
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
}
public void OnAcceptError(Exception ex, EndPoint? endpoint, TimeSpan delay)
{
_callback(ex, endpoint, delay);
}
}

View File

@@ -0,0 +1,15 @@
namespace NATS.Server.Subscriptions;
public enum InterestChangeKind
{
LocalAdded,
LocalRemoved,
RemoteAdded,
RemoteRemoved,
}
public sealed record InterestChange(
InterestChangeKind Kind,
string Subject,
string? Queue,
string Account);

View File

@@ -5,8 +5,9 @@ public sealed record RemoteSubscription(
string? Queue,
string RouteId,
string Account = "$G",
int QueueWeight = 1,
bool IsRemoval = false)
{
public static RemoteSubscription Removal(string subject, string? queue, string routeId, string account = "$G")
=> new(subject, queue, routeId, account, IsRemoval: true);
=> new(subject, queue, routeId, account, QueueWeight: 1, IsRemoval: true);
}

View File

@@ -1,3 +1,5 @@
using System.Text;
namespace NATS.Server.Subscriptions;
/// <summary>
@@ -13,6 +15,7 @@ public sealed class SubList : IDisposable
private readonly ReaderWriterLockSlim _lock = new();
private readonly TrieLevel _root = new();
private readonly SubListCacheSweeper _sweeper = new();
private readonly Dictionary<string, RemoteSubscription> _remoteSubs = new(StringComparer.Ordinal);
private Dictionary<string, CachedResult>? _cache = new(StringComparer.Ordinal);
private uint _count;
@@ -22,9 +25,12 @@ public sealed class SubList : IDisposable
private ulong _cacheHits;
private ulong _inserts;
private ulong _removes;
private int _highFanoutNodes;
private readonly record struct CachedResult(SubListResult Result, long Generation);
public event Action<InterestChange>? InterestChanged;
public void Dispose()
{
_disposed = true;
@@ -97,6 +103,10 @@ public sealed class SubList : IDisposable
}
}
internal int HighFanoutNodeCountForTest => Volatile.Read(ref _highFanoutNodes);
internal Task TriggerCacheSweepAsyncForTest() => _sweeper.TriggerSweepAsync(SweepCache);
public void ApplyRemoteSub(RemoteSubscription sub)
{
_lock.EnterWriteLock();
@@ -104,9 +114,23 @@ public sealed class SubList : IDisposable
{
var key = $"{sub.RouteId}|{sub.Account}|{sub.Subject}|{sub.Queue}";
if (sub.IsRemoval)
{
_remoteSubs.Remove(key);
InterestChanged?.Invoke(new InterestChange(
InterestChangeKind.RemoteRemoved,
sub.Subject,
sub.Queue,
sub.Account));
}
else
{
_remoteSubs[key] = sub;
InterestChanged?.Invoke(new InterestChange(
InterestChangeKind.RemoteAdded,
sub.Subject,
sub.Queue,
sub.Account));
}
Interlocked.Increment(ref _generation);
}
finally
@@ -187,6 +211,11 @@ public sealed class SubList : IDisposable
if (sub.Queue == null)
{
node.PlainSubs.Add(sub);
if (!node.PackedListEnabled && node.PlainSubs.Count > 256)
{
node.PackedListEnabled = true;
Interlocked.Increment(ref _highFanoutNodes);
}
}
else
{
@@ -201,6 +230,11 @@ public sealed class SubList : IDisposable
_count++;
_inserts++;
Interlocked.Increment(ref _generation);
InterestChanged?.Invoke(new InterestChange(
InterestChangeKind.LocalAdded,
sub.Subject,
sub.Queue,
sub.Client?.Account?.Name ?? "$G"));
}
finally
{
@@ -218,6 +252,11 @@ public sealed class SubList : IDisposable
{
_removes++;
Interlocked.Increment(ref _generation);
InterestChanged?.Invoke(new InterestChange(
InterestChangeKind.LocalRemoved,
sub.Subject,
sub.Queue,
sub.Client?.Account?.Name ?? "$G"));
}
}
finally
@@ -362,11 +401,7 @@ public sealed class SubList : IDisposable
{
_cache[subject] = new CachedResult(result, currentGen);
if (_cache.Count > CacheMax)
{
var keys = _cache.Keys.Take(_cache.Count - CacheSweep).ToList();
foreach (var key in keys)
_cache.Remove(key);
}
_sweeper.ScheduleSweep(SweepCache);
}
return result;
@@ -377,6 +412,58 @@ public sealed class SubList : IDisposable
}
}
public SubListResult MatchBytes(ReadOnlySpan<byte> subjectUtf8)
{
return Match(Encoding.ASCII.GetString(subjectUtf8));
}
public IReadOnlyList<RemoteSubscription> MatchRemote(string account, string subject)
{
_lock.EnterReadLock();
try
{
var expanded = new List<RemoteSubscription>();
foreach (var remoteSub in _remoteSubs.Values)
{
if (remoteSub.IsRemoval)
continue;
if (!string.Equals(remoteSub.Account, account, StringComparison.Ordinal))
continue;
if (!SubjectMatch.MatchLiteral(subject, remoteSub.Subject))
continue;
var weight = Math.Max(1, remoteSub.QueueWeight);
for (var i = 0; i < weight; i++)
expanded.Add(remoteSub);
}
return expanded;
}
finally
{
_lock.ExitReadLock();
}
}
private void SweepCache()
{
_lock.EnterWriteLock();
try
{
if (_cache == null || _cache.Count <= CacheMax)
return;
var removeCount = Math.Min(CacheSweep, _cache.Count - CacheMax);
var keys = _cache.Keys.Take(removeCount).ToArray();
foreach (var key in keys)
_cache.Remove(key);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// Tokenize the subject into an array of token strings.
/// Returns null if the subject is invalid (empty tokens).
@@ -879,6 +966,7 @@ public sealed class SubList : IDisposable
public TrieLevel? Next;
public readonly HashSet<Subscription> PlainSubs = [];
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
public bool PackedListEnabled;
public bool IsEmpty => PlainSubs.Count == 0 && QueueSubs.Count == 0 &&
(Next == null || (Next.Nodes.Count == 0 && Next.Pwc == null && Next.Fwc == null));

View File

@@ -0,0 +1,29 @@
namespace NATS.Server.Subscriptions;
public sealed class SubListCacheSweeper
{
private int _scheduled;
public void ScheduleSweep(Action sweep)
{
if (Interlocked.CompareExchange(ref _scheduled, 1, 0) != 0)
return;
_ = Task.Run(() =>
{
try
{
sweep();
}
finally
{
Interlocked.Exchange(ref _scheduled, 0);
}
});
}
public Task TriggerSweepAsync(Action sweep)
{
return Task.Run(sweep);
}
}

View File

@@ -0,0 +1,30 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class AuthExtensionParityTests
{
[Fact]
public void Auth_service_uses_proxy_auth_extension_when_enabled()
{
var service = AuthService.Build(new NatsOptions
{
ProxyAuth = new ProxyAuthOptions
{
Enabled = true,
UsernamePrefix = "proxy:",
},
});
service.IsAuthRequired.ShouldBeTrue();
var result = service.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "proxy:alice" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("alice");
}
}

View File

@@ -0,0 +1,58 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class ExternalAuthCalloutTests
{
[Fact]
public void External_callout_authenticator_can_allow_and_deny_with_timeout_and_reason_mapping()
{
var authenticator = new ExternalAuthCalloutAuthenticator(
new FakeExternalAuthClient(),
TimeSpan.FromMilliseconds(50));
var allowed = authenticator.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "u", Password = "p" },
Nonce = [],
});
allowed.ShouldNotBeNull();
allowed.Identity.ShouldBe("u");
var denied = authenticator.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "u", Password = "bad" },
Nonce = [],
});
denied.ShouldBeNull();
var timeout = new ExternalAuthCalloutAuthenticator(
new SlowExternalAuthClient(TimeSpan.FromMilliseconds(200)),
TimeSpan.FromMilliseconds(30));
timeout.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "u", Password = "p" },
Nonce = [],
}).ShouldBeNull();
}
private sealed class FakeExternalAuthClient : IExternalAuthClient
{
public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{
if (request is { Username: "u", Password: "p" })
return Task.FromResult(new ExternalAuthDecision(true, "u", "A"));
return Task.FromResult(new ExternalAuthDecision(false, Reason: "denied"));
}
}
private sealed class SlowExternalAuthClient(TimeSpan delay) : IExternalAuthClient
{
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{
await Task.Delay(delay, ct);
return new ExternalAuthDecision(true, "slow");
}
}
}

View File

@@ -0,0 +1,28 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class ProxyAuthTests
{
[Fact]
public void Proxy_authenticator_maps_prefixed_username_to_identity()
{
var authenticator = new ProxyAuthenticator(new ProxyAuthOptions
{
Enabled = true,
UsernamePrefix = "proxy:",
Account = "A",
});
var result = authenticator.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "proxy:bob" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("bob");
result.AccountName.ShouldBe("A");
}
}

View File

@@ -3,14 +3,11 @@ namespace NATS.Server.Tests;
public class DifferencesParityClosureTests
{
[Fact]
public void Differences_md_has_no_remaining_jetstream_baseline_or_n_rows()
public void Differences_md_has_no_remaining_baseline_n_or_stub_rows_in_tracked_scope()
{
var repositoryRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
var differencesPath = Path.Combine(repositoryRoot, "differences.md");
File.Exists(differencesPath).ShouldBeTrue();
var markdown = File.ReadAllText(differencesPath);
markdown.ShouldContain("### JetStream");
markdown.ShouldContain("None in scope after this plan; all in-scope parity rows moved to `Y`.");
var report = Parity.ParityRowInspector.Load("differences.md");
report.UnresolvedRows.ShouldBeEmpty(string.Join(
Environment.NewLine,
report.UnresolvedRows.Select(r => $"{r.Section} :: {r.SubSection} :: {r.Feature} [{r.DotNetStatus}]")));
}
}

View File

@@ -0,0 +1,17 @@
using NATS.Server.Gateways;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class GatewayInterestOnlyParityTests
{
[Fact]
public void Gateway_interest_only_mode_forwards_only_subjects_with_remote_interest()
{
using var subList = new SubList();
subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "A"));
GatewayManager.ShouldForwardInterestOnly(subList, "A", "orders.created").ShouldBeTrue();
GatewayManager.ShouldForwardInterestOnly(subList, "A", "payments.created").ShouldBeFalse();
}
}

View File

@@ -0,0 +1,17 @@
using NATS.Server.IO;
namespace NATS.Server.Tests;
public class AdaptiveReadBufferTests
{
[Fact]
public void Read_buffer_scales_between_512_and_65536_based_on_recent_payload_pattern()
{
var b = new AdaptiveReadBuffer();
b.RecordRead(512);
b.RecordRead(4096);
b.RecordRead(32000);
b.CurrentSize.ShouldBeGreaterThan(4096);
b.CurrentSize.ShouldBeLessThanOrEqualTo(64 * 1024);
}
}

View File

@@ -0,0 +1,17 @@
using NATS.Server.IO;
namespace NATS.Server.Tests;
public class OutboundBufferPoolTests
{
[Theory]
[InlineData(100, 512)]
[InlineData(1000, 4096)]
[InlineData(10000, 64 * 1024)]
public void Rent_uses_three_tier_buffer_buckets(int requested, int expectedMinimum)
{
var pool = new OutboundBufferPool();
using var owner = pool.Rent(requested);
owner.Memory.Length.ShouldBeGreaterThanOrEqualTo(expectedMinimum);
}
}

View File

@@ -0,0 +1,26 @@
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Models;
namespace NATS.Server.Tests;
public class JetStreamClusterGovernanceRuntimeParityTests
{
[Fact]
public async Task Jetstream_cluster_governance_applies_consensus_backed_placement()
{
var meta = new JetStreamMetaGroup(3);
await meta.ProposeCreateStreamAsync(new StreamConfig
{
Name = "ORDERS",
Subjects = ["orders.*"],
}, default);
var planner = new AssetPlacementPlanner(3);
var placement = planner.PlanReplicas(2);
var replicas = new StreamReplicaGroup("ORDERS", 1);
await replicas.ApplyPlacementAsync(placement, default);
meta.GetState().Streams.ShouldContain("ORDERS");
replicas.Nodes.Count.ShouldBe(2);
}
}

View File

@@ -0,0 +1,33 @@
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;
using NATS.Server.JetStream;
namespace NATS.Server.Tests;
public class JetStreamConsumerFlowReplayParityTests
{
[Fact]
public void Push_consumer_enqueues_flow_control_and_heartbeat_frames_when_enabled()
{
var engine = new PushConsumerEngine();
var consumer = new ConsumerHandle("ORDERS", new ConsumerConfig
{
AckPolicy = AckPolicy.Explicit,
FlowControl = true,
HeartbeatMs = 1000,
RateLimitBps = 1024,
});
engine.Enqueue(consumer, new StoredMessage
{
Sequence = 1,
Subject = "orders.created",
Payload = "payload"u8.ToArray(),
});
consumer.PushFrames.Count.ShouldBe(3);
consumer.PushFrames.Any(f => f.IsFlowControl).ShouldBeTrue();
consumer.PushFrames.Any(f => f.IsHeartbeat).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,25 @@
using NATS.Server.JetStream.Consumers;
namespace NATS.Server.Tests;
public class JetStreamConsumerRuntimeParityTests
{
[Fact]
public async Task Consumer_runtime_honors_ack_all_redelivery_and_max_deliver_limits()
{
var ack = new AckProcessor();
ack.Register(1, ackWaitMs: 1);
await Task.Delay(5);
ack.TryGetExpired(out var seq, out var deliveries).ShouldBeTrue();
seq.ShouldBe((ulong)1);
deliveries.ShouldBe(1);
ack.ScheduleRedelivery(seq, delayMs: 1);
await Task.Delay(5);
ack.TryGetExpired(out _, out deliveries).ShouldBeTrue();
deliveries.ShouldBe(2);
ack.AckAll(1);
ack.HasPending.ShouldBeFalse();
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Gateways;
namespace NATS.Server.Tests;
public class JetStreamCrossClusterRuntimeParityTests
{
[Fact]
public async Task Jetstream_cross_cluster_messages_are_forward_counted()
{
var manager = new GatewayManager(
new GatewayOptions { Host = "127.0.0.1", Port = 0, Name = "A" },
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<GatewayManager>.Instance);
await manager.ForwardJetStreamClusterMessageAsync(
new GatewayMessage("$JS.CLUSTER.REPL", null, "x"u8.ToArray()),
default);
manager.ForwardedJetStreamClusterMessages.ShouldBe(1);
}
}

View File

@@ -0,0 +1,33 @@
using NATS.Server.JetStream.Storage;
namespace NATS.Server.Tests;
public class JetStreamFileStoreCryptoCompressionTests
{
[Fact]
public async Task File_store_compression_and_encryption_roundtrip_preserves_payload()
{
var dir = Path.Combine(Path.GetTempPath(), $"natsdotnet-filestore-crypto-{Guid.NewGuid():N}");
try
{
await using var store = new FileStore(new FileStoreOptions
{
Directory = dir,
EnableCompression = true,
EnableEncryption = true,
EncryptionKey = [1, 2, 3, 4],
});
var payload = Enumerable.Repeat((byte)'a', 512).ToArray();
var seq = await store.AppendAsync("orders.created", payload, default);
var loaded = await store.LoadAsync(seq, default);
loaded.ShouldNotBeNull();
loaded.Payload.ToArray().ShouldBe(payload);
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}

View File

@@ -0,0 +1,33 @@
using NATS.Server.JetStream.Storage;
namespace NATS.Server.Tests;
public class JetStreamFileStoreLayoutParityTests
{
[Fact]
public async Task File_store_uses_block_index_layout_with_ttl_prune_invariants()
{
var dir = Path.Combine(Path.GetTempPath(), $"natsdotnet-filestore-{Guid.NewGuid():N}");
try
{
await using var store = new FileStore(new FileStoreOptions
{
Directory = dir,
BlockSizeBytes = 128,
MaxAgeMs = 60_000,
});
for (var i = 0; i < 100; i++)
await store.AppendAsync($"orders.{i}", "x"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)100);
store.BlockCount.ShouldBeGreaterThan(1);
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}

View File

@@ -0,0 +1,40 @@
using NATS.Server.JetStream.MirrorSource;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;
namespace NATS.Server.Tests;
public class JetStreamMirrorSourceRuntimeParityTests
{
[Fact]
public async Task Mirror_source_runtime_tracks_sync_state_and_subject_mapping()
{
var mirrorTarget = new MemStore();
var sourceTarget = new MemStore();
var mirror = new MirrorCoordinator(mirrorTarget);
var source = new SourceCoordinator(sourceTarget, new StreamSourceConfig
{
Name = "SRC",
SubjectTransformPrefix = "agg.",
SourceAccount = "A",
});
var message = new StoredMessage
{
Sequence = 10,
Subject = "orders.created",
Payload = "ok"u8.ToArray(),
TimestampUtc = DateTime.UtcNow,
};
await mirror.OnOriginAppendAsync(message, default);
await source.OnOriginAppendAsync(message, default);
mirror.LastOriginSequence.ShouldBe((ulong)10);
source.LastOriginSequence.ShouldBe((ulong)10);
var sourced = await sourceTarget.LoadAsync(1, default);
sourced.ShouldNotBeNull();
sourced.Subject.ShouldBe("agg.orders.created");
}
}

View File

@@ -0,0 +1,30 @@
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Validation;
namespace NATS.Server.Tests;
public class JetStreamStreamFeatureToggleParityTests
{
[Fact]
public void Stream_feature_toggles_are_preserved_in_config_model_and_validation()
{
var config = new StreamConfig
{
Name = "ORDERS",
Subjects = ["orders.*"],
Sealed = true,
DenyDelete = true,
DenyPurge = true,
AllowDirect = true,
MaxMsgSize = 1024,
MaxMsgsPer = 10,
MaxAgeMs = 5000,
};
JetStreamConfigValidator.Validate(config).IsValid.ShouldBeTrue();
config.Sealed.ShouldBeTrue();
config.DenyDelete.ShouldBeTrue();
config.DenyPurge.ShouldBeTrue();
config.AllowDirect.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,29 @@
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
using NATS.Server.JetStream.Validation;
namespace NATS.Server.Tests;
public class JetStreamStreamRuntimeParityTests
{
[Fact]
public void Stream_runtime_enforces_retention_and_size_preconditions()
{
var invalid = new StreamConfig
{
Name = "ORDERS",
Subjects = ["orders.*"],
Retention = RetentionPolicy.WorkQueue,
MaxConsumers = 0,
MaxMsgSize = -1,
};
var result = JetStreamConfigValidator.Validate(invalid);
result.IsValid.ShouldBeFalse();
var preconditions = new PublishPreconditions();
preconditions.Record("m1", 1);
preconditions.IsDuplicate("m1", duplicateWindowMs: 10_000, out var existing).ShouldBeTrue();
existing.ShouldBe((ulong)1);
}
}

View File

@@ -0,0 +1,21 @@
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests;
public class LeafHubSpokeMappingParityTests
{
[Fact]
public void Leaf_hub_spoke_mapper_round_trips_account_mapping()
{
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>
{
["HUB"] = "SPOKE",
});
var outbound = mapper.Map("HUB", "orders.created", LeafMapDirection.Outbound);
outbound.Account.ShouldBe("SPOKE");
var inbound = mapper.Map("SPOKE", "orders.created", LeafMapDirection.Inbound);
inbound.Account.ShouldBe("HUB");
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json;
namespace NATS.Server.Tests;
public class ConnzParityFieldTests
{
[Fact]
public async Task Connz_includes_identity_tls_and_proxy_parity_fields()
{
await using var fx = await MonitoringParityFixture.StartAsync();
await fx.ConnectClientAsync("u", "orders.created");
var connz = fx.GetConnz("?subs=detail");
connz.Conns.ShouldNotBeEmpty();
var json = JsonSerializer.Serialize(connz);
json.ShouldContain("tls_peer_cert_subject");
json.ShouldContain("jwt_issuer_key");
json.ShouldContain("proxy");
}
}

View File

@@ -0,0 +1,115 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Auth;
using NATS.Server.Monitoring;
namespace NATS.Server.Tests;
public class ConnzParityFilterTests
{
[Fact]
public async Task Connz_filters_by_user_account_and_subject_and_includes_tls_peer_and_jwt_metadata()
{
await using var fx = await MonitoringParityFixture.StartAsync();
await fx.ConnectClientAsync("u", "orders.created");
await fx.ConnectClientAsync("v", "payments.created");
var connz = fx.GetConnz("?user=u&acc=A&filter_subject=orders.*&subs=detail");
connz.Conns.ShouldAllBe(c => c.Account == "A" && c.AuthorizedUser == "u");
}
}
internal sealed class MonitoringParityFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private readonly List<TcpClient> _clients = [];
private readonly NatsOptions _options;
private MonitoringParityFixture(NatsServer server, NatsOptions options, CancellationTokenSource cts)
{
_server = server;
_options = options;
_cts = cts;
}
public static async Task<MonitoringParityFixture> StartAsync()
{
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users =
[
new User { Username = "u", Password = "p", Account = "A" },
new User { Username = "v", Password = "p", Account = "B" },
],
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return new MonitoringParityFixture(server, options, cts);
}
public async Task ConnectClientAsync(string username, string? subscribeSubject)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, _server.Port);
_clients.Add(client);
var stream = client.GetStream();
await ReadLineAsync(stream); // INFO
var connect = $"CONNECT {{\"user\":\"{username}\",\"pass\":\"p\"}}\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(connect));
if (!string.IsNullOrEmpty(subscribeSubject))
await stream.WriteAsync(Encoding.ASCII.GetBytes($"SUB {subscribeSubject} sid-{username}\r\n"));
await stream.FlushAsync();
await Task.Delay(30);
}
public Connz GetConnz(string queryString)
{
var ctx = new DefaultHttpContext();
ctx.Request.QueryString = new QueryString(queryString);
return new ConnzHandler(_server).HandleConnz(ctx);
}
public async Task<Varz> GetVarzAsync()
{
using var handler = new VarzHandler(_server, _options);
return await handler.HandleVarzAsync();
}
public async ValueTask DisposeAsync()
{
foreach (var client in _clients)
client.Dispose();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
private static async Task<string> ReadLineAsync(NetworkStream stream)
{
var bytes = new List<byte>();
var one = new byte[1];
while (true)
{
var read = await stream.ReadAsync(one.AsMemory(0, 1));
if (read == 0)
break;
if (one[0] == (byte)'\n')
break;
if (one[0] != (byte)'\r')
bytes.Add(one[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
}

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
namespace NATS.Server.Tests;
public class PprofEndpointTests
{
[Fact]
public async Task Debug_pprof_endpoint_returns_profile_index_when_profport_enabled()
{
await using var fx = await PprofMonitorFixture.StartWithProfilingAsync();
var body = await fx.GetStringAsync("/debug/pprof");
body.ShouldContain("profiles");
}
}
internal sealed class PprofMonitorFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _http;
private readonly int _monitorPort;
private PprofMonitorFixture(NatsServer server, CancellationTokenSource cts, HttpClient http, int monitorPort)
{
_server = server;
_cts = cts;
_http = http;
_monitorPort = monitorPort;
}
public static async Task<PprofMonitorFixture> StartWithProfilingAsync()
{
var monitorPort = GetFreePort();
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
MonitorPort = monitorPort,
ProfPort = monitorPort,
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
var http = new HttpClient();
for (var i = 0; i < 50; i++)
{
try
{
var response = await http.GetAsync($"http://127.0.0.1:{monitorPort}/healthz");
if (response.IsSuccessStatusCode)
break;
}
catch
{
}
await Task.Delay(50);
}
return new PprofMonitorFixture(server, cts, http, monitorPort);
}
public Task<string> GetStringAsync(string path)
{
return _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}{path}");
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
private static int GetFreePort()
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)socket.LocalEndPoint!).Port;
}
}

View File

@@ -0,0 +1,17 @@
namespace NATS.Server.Tests;
public class VarzSlowConsumerBreakdownTests
{
[Fact]
public async Task Varz_contains_slow_consumer_breakdown_fields()
{
await using var fx = await MonitoringParityFixture.StartAsync();
var varz = await fx.GetVarzAsync();
varz.SlowConsumerStats.ShouldNotBeNull();
varz.SlowConsumerStats.Clients.ShouldBeGreaterThanOrEqualTo((ulong)0);
varz.SlowConsumerStats.Routes.ShouldBeGreaterThanOrEqualTo((ulong)0);
varz.SlowConsumerStats.Gateways.ShouldBeGreaterThanOrEqualTo((ulong)0);
varz.SlowConsumerStats.Leafs.ShouldBeGreaterThanOrEqualTo((ulong)0);
}
}

View File

@@ -0,0 +1,73 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using NATS.Server.Mqtt;
namespace NATS.Server.Tests;
public class MqttListenerParityTests
{
[Fact]
public async Task Mqtt_listener_accepts_connect_and_routes_publish_to_matching_subscription()
{
await using var listener = new MqttListener("127.0.0.1", 0);
using var cts = new CancellationTokenSource();
await listener.StartAsync(cts.Token);
using var sub = new TcpClient();
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
var subStream = sub.GetStream();
await MqttTestWire.WriteLineAsync(subStream, "CONNECT sub");
(await MqttTestWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
await MqttTestWire.WriteLineAsync(subStream, "SUB sensors.temp");
var subAck = await MqttTestWire.ReadLineAsync(subStream, 1000);
subAck.ShouldNotBeNull();
subAck.ShouldContain("SUBACK");
using var pub = new TcpClient();
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
var pubStream = pub.GetStream();
await MqttTestWire.WriteLineAsync(pubStream, "CONNECT pub");
_ = await MqttTestWire.ReadLineAsync(pubStream, 1000);
await MqttTestWire.WriteLineAsync(pubStream, "PUB sensors.temp 42");
var message = await MqttTestWire.ReadLineAsync(subStream, 1000);
message.ShouldBe("MSG sensors.temp 42");
}
}
internal static class MqttTestWire
{
public static async Task WriteLineAsync(NetworkStream stream, string line)
{
var bytes = Encoding.UTF8.GetBytes(line + "\n");
await stream.WriteAsync(bytes);
await stream.FlushAsync();
}
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
{
using var timeout = new CancellationTokenSource(timeoutMs);
var bytes = new List<byte>();
var one = new byte[1];
try
{
while (true)
{
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
if (read == 0)
return null;
if (one[0] == (byte)'\n')
break;
if (one[0] != (byte)'\r')
bytes.Add(one[0]);
}
}
catch (OperationCanceledException)
{
return null;
}
return Encoding.UTF8.GetString([.. bytes]);
}
}

View File

@@ -0,0 +1,33 @@
using System.Net;
using System.Net.Sockets;
using NATS.Server.Mqtt;
namespace NATS.Server.Tests;
public class MqttPublishSubscribeParityTests
{
[Fact]
public async Task Mqtt_publish_only_reaches_matching_topic_subscribers()
{
await using var listener = new MqttListener("127.0.0.1", 0);
using var cts = new CancellationTokenSource();
await listener.StartAsync(cts.Token);
using var sub = new TcpClient();
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
var subStream = sub.GetStream();
await MqttTestWire.WriteLineAsync(subStream, "CONNECT sub");
_ = await MqttTestWire.ReadLineAsync(subStream, 1000);
await MqttTestWire.WriteLineAsync(subStream, "SUB sensors.temp");
_ = await MqttTestWire.ReadLineAsync(subStream, 1000);
using var pub = new TcpClient();
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
var pubStream = pub.GetStream();
await MqttTestWire.WriteLineAsync(pubStream, "CONNECT pub");
_ = await MqttTestWire.ReadLineAsync(pubStream, 1000);
await MqttTestWire.WriteLineAsync(pubStream, "PUB sensors.humidity 90");
(await MqttTestWire.ReadLineAsync(subStream, 150)).ShouldBeNull();
}
}

View File

@@ -0,0 +1,67 @@
namespace NATS.Server.Tests.Parity;
public sealed record ParityRow(string Section, string SubSection, string Feature, string DotNetStatus);
public sealed class ParityReport
{
public ParityReport(IReadOnlyList<ParityRow> rows)
{
Rows = rows;
}
public IReadOnlyList<ParityRow> Rows { get; }
public IReadOnlyList<ParityRow> UnresolvedRows =>
Rows.Where(r => r.DotNetStatus is "N" or "Baseline" or "Stub").ToArray();
}
public static class ParityRowInspector
{
public static ParityReport Load(string relativePath)
{
var repositoryRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
var differencesPath = Path.Combine(repositoryRoot, relativePath);
File.Exists(differencesPath).ShouldBeTrue();
var section = string.Empty;
var subsection = string.Empty;
var rows = new List<ParityRow>();
foreach (var rawLine in File.ReadLines(differencesPath))
{
var line = rawLine.Trim();
if (line.StartsWith("## ", StringComparison.Ordinal))
{
section = line[3..].Trim();
continue;
}
if (line.StartsWith("### ", StringComparison.Ordinal))
{
subsection = line[4..].Trim();
continue;
}
if (!line.StartsWith("|", StringComparison.Ordinal))
continue;
if (line.Contains("---", StringComparison.Ordinal))
continue;
var cells = line.Trim('|').Split('|').Select(c => c.Trim()).ToArray();
if (cells.Length < 3)
continue;
// Ignore table header rows; row format is expected to contain Go and .NET status columns.
if (cells[0] is "Feature" or "Aspect" or "Operation" or "Signal" or "Type" or "Mechanism" or "Flag")
continue;
rows.Add(new ParityRow(
section,
subsection,
cells[0],
cells[2]));
}
return new ParityReport(rows);
}
}

View File

@@ -0,0 +1,14 @@
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class InterServerOpcodeRoutingTests
{
[Fact]
public void Parser_dispatch_rejects_Aplus_for_client_kind_client_but_allows_for_gateway()
{
var m = new ClientCommandMatrix();
m.IsAllowed(ClientKind.Client, "A+").ShouldBeFalse();
m.IsAllowed(ClientKind.Gateway, "A+").ShouldBeTrue();
}
}

View File

@@ -0,0 +1,24 @@
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class MessageTraceInitializationTests
{
[Fact]
public void Trace_context_is_initialized_from_connect_options()
{
var connectOpts = new ClientOptions
{
Name = "c1",
Lang = "dotnet",
Version = "1.0.0",
Headers = true,
};
var ctx = MessageTraceContext.CreateFromConnect(connectOpts);
ctx.ClientName.ShouldBe("c1");
ctx.ClientLang.ShouldBe("dotnet");
ctx.ClientVersion.ShouldBe("1.0.0");
ctx.HeadersEnabled.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,16 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftConsensusRuntimeParityTests
{
[Fact]
public async Task Raft_cluster_commits_with_next_index_backtracking_semantics()
{
var cluster = RaftTestCluster.Create(3);
await cluster.GenerateCommittedEntriesAsync(5);
await cluster.WaitForAppliedAsync(5);
cluster.Nodes.All(n => n.AppliedIndex >= 5).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,19 @@
using NATS.Server.Raft;
namespace NATS.Server.Tests;
public class RaftMembershipRuntimeParityTests
{
[Fact]
public void Raft_membership_add_remove_round_trips()
{
var node = new RaftNode("N1");
node.AddMember("N2");
node.AddMember("N3");
node.Members.ShouldContain("N2");
node.Members.ShouldContain("N3");
node.RemoveMember("N2");
node.Members.ShouldNotContain("N2");
}
}

View File

@@ -0,0 +1,15 @@
namespace NATS.Server.Tests;
public class RaftSnapshotTransferRuntimeParityTests
{
[Fact]
public async Task Raft_snapshot_install_catches_up_lagging_follower()
{
var cluster = RaftTestCluster.Create(3);
await cluster.GenerateCommittedEntriesAsync(3);
await cluster.RestartLaggingFollowerAsync();
await cluster.WaitForFollowerCatchupAsync();
cluster.LaggingFollower.AppliedIndex.ShouldBeGreaterThan(0);
}
}

View File

@@ -0,0 +1,14 @@
using NATS.Server.Routes;
namespace NATS.Server.Tests;
public class RouteAccountScopedTests
{
[Fact]
public void Route_connect_info_includes_account_scope()
{
var json = RouteConnection.BuildConnectInfoJson("S1", ["A"], "topology-v1");
json.ShouldContain("\"accounts\":[\"A\"]");
json.ShouldContain("\"topology\":\"topology-v1\"");
}
}

View File

@@ -0,0 +1,16 @@
using System.Text;
using NATS.Server.Routes;
namespace NATS.Server.Tests;
public class RouteCompressionTests
{
[Fact]
public void Route_payload_round_trips_through_compression_codec()
{
var payload = Encoding.UTF8.GetBytes(new string('x', 512));
var compressed = RouteCompressionCodec.Compress(payload);
var restored = RouteCompressionCodec.Decompress(compressed);
restored.ShouldBe(payload);
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.Routes;
namespace NATS.Server.Tests;
public class RouteTopologyGossipTests
{
[Fact]
public void Topology_snapshot_reports_server_and_route_counts()
{
var manager = new RouteManager(
new ClusterOptions { Host = "127.0.0.1", Port = 0 },
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<RouteManager>.Instance);
var snapshot = manager.BuildTopologySnapshot();
snapshot.ServerId.ShouldBe("S1");
snapshot.RouteCount.ShouldBe(0);
snapshot.ConnectedServerIds.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,41 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Server;
namespace NATS.Server.Tests;
public class AcceptLoopErrorCallbackTests
{
[Fact]
public void Accept_loop_reports_error_via_callback_hook()
{
var server = new NatsServer(new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
}, NullLoggerFactory.Instance);
Exception? capturedError = null;
EndPoint? capturedEndpoint = null;
var capturedDelay = TimeSpan.Zero;
var handler = new AcceptLoopErrorHandler((ex, endpoint, delay) =>
{
capturedError = ex;
capturedEndpoint = endpoint;
capturedDelay = delay;
});
server.SetAcceptLoopErrorHandlerForTest(handler);
var endpoint = new IPEndPoint(IPAddress.Loopback, 4222);
var error = new SocketException((int)SocketError.ConnectionReset);
var delay = TimeSpan.FromMilliseconds(20);
server.NotifyAcceptErrorForTest(error, endpoint, delay);
capturedError.ShouldBe(error);
capturedEndpoint.ShouldBe(endpoint);
capturedDelay.ShouldBe(delay);
}
}

View File

@@ -0,0 +1,84 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
namespace NATS.Server.Tests;
public class AcceptLoopReloadLockTests
{
[Fact]
public async Task Accept_loop_blocks_client_creation_while_reload_lock_is_held()
{
await using var fx = await AcceptLoopFixture.StartAsync();
await fx.HoldReloadLockAsync();
(await fx.TryConnectClientAsync(timeoutMs: 150)).ShouldBeFalse();
fx.ReleaseReloadLock();
}
}
internal sealed class AcceptLoopFixture : IAsyncDisposable
{
private readonly NatsServer _server;
private readonly CancellationTokenSource _cts;
private bool _reloadHeld;
private AcceptLoopFixture(NatsServer server, CancellationTokenSource cts)
{
_server = server;
_cts = cts;
}
public static async Task<AcceptLoopFixture> StartAsync()
{
var server = new NatsServer(new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
}, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return new AcceptLoopFixture(server, cts);
}
public async Task HoldReloadLockAsync()
{
await _server.AcquireReloadLockForTestAsync();
_reloadHeld = true;
}
public void ReleaseReloadLock()
{
if (_reloadHeld)
{
_server.ReleaseReloadLockForTest();
_reloadHeld = false;
}
}
public async Task<bool> TryConnectClientAsync(int timeoutMs)
{
using var client = new TcpClient();
using var timeout = new CancellationTokenSource(timeoutMs);
await client.ConnectAsync(IPAddress.Loopback, _server.Port, timeout.Token);
await using var stream = client.GetStream();
var buffer = new byte[1];
try
{
var read = await stream.ReadAsync(buffer.AsMemory(0, 1), timeout.Token);
return read > 0;
}
catch (OperationCanceledException)
{
return false;
}
}
public async ValueTask DisposeAsync()
{
ReleaseReloadLock();
await _cts.CancelAsync();
_server.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,22 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubListAsyncCacheSweepTests
{
[Fact]
public async Task Cache_sweep_runs_async_and_prunes_stale_entries_without_write_locking_match_path()
{
using var sl = new SubList();
sl.Insert(new Subscription { Subject = ">", Sid = "all" });
for (var i = 0; i < 1500; i++)
_ = sl.Match($"orders.{i}");
var initial = sl.CacheCount;
initial.ShouldBeGreaterThan(1024);
await sl.TriggerCacheSweepAsyncForTest();
sl.CacheCount.ShouldBeLessThan(initial);
}
}

View File

@@ -0,0 +1,22 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubListHighFanoutOptimizationTests
{
[Fact]
public void High_fanout_nodes_enable_packed_list_optimization()
{
using var sl = new SubList();
for (var i = 0; i < 300; i++)
{
sl.Insert(new Subscription
{
Subject = "orders.created",
Sid = i.ToString(),
});
}
sl.HighFanoutNodeCountForTest.ShouldBeGreaterThan(0);
}
}

View File

@@ -0,0 +1,16 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubListMatchBytesTests
{
[Fact]
public void MatchBytes_matches_subject_without_string_allocation_and_respects_remote_filter()
{
using var sl = new SubList();
sl.MatchBytes("orders.created"u8).PlainSubs.Length.ShouldBe(0);
sl.Insert(new Subscription { Subject = "orders.*", Sid = "1" });
sl.MatchBytes("orders.created"u8).PlainSubs.Length.ShouldBe(1);
}
}

View File

@@ -0,0 +1,24 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubListNotificationTests
{
[Fact]
public void Interest_change_notifications_are_emitted_for_local_and_remote_changes()
{
using var sl = new SubList();
var changes = new List<InterestChange>();
sl.InterestChanged += changes.Add;
var sub = new Subscription { Subject = "orders.created", Sid = "1" };
sl.Insert(sub);
sl.Remove(sub);
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
sl.ApplyRemoteSub(RemoteSubscription.Removal("orders.*", null, "r1", "A"));
changes.Count.ShouldBe(4);
changes.Select(c => c.Kind).ShouldContain(InterestChangeKind.LocalAdded);
changes.Select(c => c.Kind).ShouldContain(InterestChangeKind.RemoteAdded);
}
}

View File

@@ -0,0 +1,17 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubListQueueWeightTests
{
[Fact]
public void Remote_queue_weight_expands_matches()
{
using var sl = new SubList();
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", "q", "r1", "A", QueueWeight: 3));
var matches = sl.MatchRemote("A", "orders.created");
matches.Count.ShouldBe(3);
matches.ShouldAllBe(m => m.Queue == "q");
}
}

View File

@@ -0,0 +1,18 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class SubListRemoteFilterTests
{
[Fact]
public void Match_remote_filters_by_account_and_subject()
{
using var sl = new SubList();
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r2", "B"));
var aMatches = sl.MatchRemote("A", "orders.created");
aMatches.Count.ShouldBe(1);
aMatches[0].Account.ShouldBe("A");
}
}