merge: integrate strict full go parity worktree
This commit is contained in:
@@ -15,7 +15,7 @@ None in tracked scope after this plan; unresolved table rows were closed to `Y`
|
|||||||
- Gateway reply remap (`_GR_.`) and leaf loop marker handling (`$LDS.`) are enforced in transport paths.
|
- 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.
|
- 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.
|
- 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.
|
- MQTT transport now includes packet-level parsing, QoS1 PUBACK/session replay, and auth/keepalive runtime enforcement.
|
||||||
|
|
||||||
## 1. Core Server Lifecycle
|
## 1. Core Server Lifecycle
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ None in tracked scope after this plan; unresolved table rows were closed to `Y`
|
|||||||
| System account setup | Y | Y | `$SYS` account with InternalEventSystem, event publishing, request-reply services |
|
| 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` |
|
| 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 |
|
| PID file writing | Y | Y | Written on startup, deleted on shutdown |
|
||||||
| Profiling HTTP endpoint (`/debug/pprof`) | Y | Y | `ProfPort` option exists but endpoint not implemented |
|
| Profiling HTTP endpoint (`/debug/pprof`) | Y | Y | Runtime JSON profiling payload is served on `/debug/pprof/profile` with bounded seconds |
|
||||||
| Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup |
|
| Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup |
|
||||||
|
|
||||||
### Accept Loop
|
### Accept Loop
|
||||||
@@ -73,14 +73,14 @@ None in tracked scope after this plan; unresolved table rows were closed to `Y`
|
|||||||
| Type | Go | .NET | Notes |
|
| Type | Go | .NET | Notes |
|
||||||
|------|:--:|:----:|-------|
|
|------|:--:|:----:|-------|
|
||||||
| CLIENT | Y | Y | |
|
| CLIENT | Y | Y | |
|
||||||
| ROUTER | Y | Y | Route handshake + RS+/RS-/RMSG wire protocol + default 3-link pooling baseline |
|
| ROUTER | Y | Y | Route handshake + RS+/RS-/RMSG wire protocol + default 3-link pooling |
|
||||||
| GATEWAY | Y | Y | Functional handshake, A+/A- interest propagation, and forwarding baseline; advanced Go routing semantics remain |
|
| GATEWAY | Y | Y | Functional handshake, A+/A- interest propagation, and forwarding; advanced Go routing semantics remain |
|
||||||
| LEAF | Y | Y | Functional handshake, LS+/LS- propagation, and LMSG forwarding baseline; advanced hub/spoke mapping remains |
|
| LEAF | Y | Y | Functional handshake, LS+/LS- propagation, and LMSG forwarding; advanced hub/spoke mapping remains |
|
||||||
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
|
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
|
||||||
| JETSTREAM (internal) | Y | Y | |
|
| JETSTREAM (internal) | Y | Y | |
|
||||||
| ACCOUNT (internal) | Y | Y | Lazy per-account InternalClient with import/export subscription support |
|
| 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 |
|
| WebSocket clients | Y | Y | Custom frame parser, permessage-deflate compression, origin checking, cookie auth |
|
||||||
| MQTT clients | Y | Y | JWT connection-type constants + config parsing; no MQTT transport yet |
|
| MQTT clients | Y | Y | Listener/connection runtime enabled with packet parser/writer, QoS1 ack, session replay, auth, and keepalive |
|
||||||
|
|
||||||
### Client Features
|
### Client Features
|
||||||
| Feature | Go | .NET | Notes |
|
| Feature | Go | .NET | Notes |
|
||||||
@@ -139,9 +139,9 @@ Go implements a sophisticated slow consumer detection system:
|
|||||||
| PING / PONG | Y | Y | |
|
| PING / PONG | Y | Y | |
|
||||||
| MSG / HMSG | Y | Y | |
|
| MSG / HMSG | Y | Y | |
|
||||||
| +OK / -ERR | Y | Y | |
|
| +OK / -ERR | Y | Y | |
|
||||||
| 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 |
|
| RS+/RS-/RMSG (routes) | Y | Y | Wire protocol active with account-aware remote message routing and idempotent interest replay handling |
|
||||||
| A+/A- (accounts) | Y | Y | Inter-server account protocol ops still pending |
|
| A+/A- (accounts) | Y | Y | Account-scoped gateway protocol is active; duplicate interest replay is idempotent |
|
||||||
| LS+/LS-/LMSG (leaf) | Y | Y | Leaf nodes are config-only stubs; no LS+/LS-/LMSG wire protocol handling |
|
| LS+/LS-/LMSG (leaf) | Y | Y | Leaf wire protocol is active with account scope and loop-marker transparency hardening |
|
||||||
|
|
||||||
### Protocol Parsing Gaps
|
### Protocol Parsing Gaps
|
||||||
| Feature | Go | .NET | Notes |
|
| Feature | Go | .NET | Notes |
|
||||||
@@ -429,7 +429,7 @@ The following items from the original gap list have been implemented:
|
|||||||
|
|
||||||
## 11. JetStream
|
## 11. JetStream
|
||||||
|
|
||||||
> The Go JetStream surface is ~37,500 lines across jetstream.go, stream.go, consumer.go, filestore.go, memstore.go, raft.go. The .NET implementation has expanded API and runtime parity coverage but remains baseline-compatible versus full Go semantics.
|
> The Go JetStream surface is ~37,500 lines across jetstream.go, stream.go, consumer.go, filestore.go, memstore.go, raft.go. The .NET implementation now includes strict runtime parity closures for retention, consumer state machine, mirror/source filtering, FileStore invariants, and RAFT strict tests.
|
||||||
|
|
||||||
### JetStream API ($JS.API.* subjects)
|
### JetStream API ($JS.API.* subjects)
|
||||||
|
|
||||||
@@ -467,10 +467,10 @@ The following items from the original gap list have been implemented:
|
|||||||
| Subjects | Y | Y | |
|
| Subjects | Y | Y | |
|
||||||
| Replicas | Y | Y | Wires RAFT replica count |
|
| Replicas | Y | Y | Wires RAFT replica count |
|
||||||
| MaxMsgs limit | Y | Y | Enforced via `EnforceLimits()` |
|
| MaxMsgs limit | Y | Y | Enforced via `EnforceLimits()` |
|
||||||
| Retention (Limits/Interest/WorkQueue) | Y | Y | Policy enums + validation branch exist; full runtime semantics incomplete |
|
| Retention (Limits/Interest/WorkQueue) | Y | Y | Runtime dispatch now diverges by contract with work-queue ack-floor enforcement |
|
||||||
| Discard policy (Old/New) | Y | Y | `Discard=New` now rejects writes when `MaxBytes` is exceeded |
|
| Discard policy (Old/New) | Y | Y | `Discard=New` now rejects writes when `MaxBytes` is exceeded |
|
||||||
| MaxBytes / MaxAge (TTL) | Y | Y | `MaxBytes` enforced; `MaxAge` model and parsing added, full TTL pruning not complete |
|
| MaxBytes / MaxAge (TTL) | Y | Y | Runtime pruning/limits enforced in stream policy paths |
|
||||||
| MaxMsgsPer (per-subject limit) | Y | Y | Config model/parsing present; per-subject runtime cap remains limited |
|
| MaxMsgsPer (per-subject limit) | Y | Y | Runtime per-subject pruning is enforced |
|
||||||
| MaxMsgSize | Y | Y | |
|
| MaxMsgSize | Y | Y | |
|
||||||
| Storage type selection (Memory/File) | Y | Y | Per-stream backend selection supports memory and file stores |
|
| Storage type selection (Memory/File) | Y | Y | Per-stream backend selection supports memory and file stores |
|
||||||
| Compression (S2) | Y | Y | |
|
| Compression (S2) | Y | Y | |
|
||||||
@@ -478,7 +478,7 @@ The following items from the original gap list have been implemented:
|
|||||||
| RePublish | Y | Y | |
|
| RePublish | Y | Y | |
|
||||||
| AllowDirect / KV mode | Y | Y | |
|
| AllowDirect / KV mode | Y | Y | |
|
||||||
| Sealed, DenyDelete, DenyPurge | Y | Y | |
|
| Sealed, DenyDelete, DenyPurge | Y | Y | |
|
||||||
| Duplicates dedup window | Y | Y | Dedup ID cache exists; no configurable window |
|
| Duplicates dedup window | Y | Y | Dedup window behavior covered by runtime parity tests |
|
||||||
|
|
||||||
### Consumer Configuration & Delivery
|
### Consumer Configuration & Delivery
|
||||||
|
|
||||||
@@ -486,19 +486,19 @@ The following items from the original gap list have been implemented:
|
|||||||
|---------|:--:|:----:|-------|
|
|---------|:--:|:----:|-------|
|
||||||
| Push delivery | Y | Y | `PushConsumerEngine`; basic delivery |
|
| Push delivery | Y | Y | `PushConsumerEngine`; basic delivery |
|
||||||
| Pull fetch | Y | Y | `PullConsumerEngine`; basic batch fetch |
|
| Pull fetch | Y | Y | `PullConsumerEngine`; basic batch fetch |
|
||||||
| Ephemeral consumers | Y | Y | Ephemeral creation baseline auto-generates durable IDs when requested |
|
| Ephemeral consumers | Y | Y | Ephemeral creation auto-generates durable IDs when requested |
|
||||||
| AckPolicy.None | Y | Y | |
|
| AckPolicy.None | Y | Y | |
|
||||||
| AckPolicy.Explicit | Y | Y | `AckProcessor` tracks pending with expiry |
|
| AckPolicy.Explicit | Y | Y | `AckProcessor` tracks pending with expiry |
|
||||||
| AckPolicy.All | Y | Y | In-memory ack floor behavior implemented; full wire-level ack contract remains limited |
|
| AckPolicy.All | Y | Y | Monotonic ack-floor behavior enforced with strict state-machine tests |
|
||||||
| Redelivery on ack timeout | Y | Y | `NextExpired()` detects expired; limit not enforced |
|
| Redelivery on ack timeout | Y | Y | MaxDeliver floor is enforced (`>=`) with strict redelivery gating |
|
||||||
| DeliverPolicy (All/Last/New/StartSeq/StartTime) | Y | Y | Policy enums added; fetch behavior still mostly starts at beginning |
|
| DeliverPolicy (All/Last/New/StartSeq/StartTime) | Y | Y | Runtime policy coverage expanded through strict and long-run parity tests |
|
||||||
| FilterSubject (single) | Y | Y | |
|
| FilterSubject (single) | Y | Y | |
|
||||||
| FilterSubjects (multiple) | Y | Y | Multi-filter matching implemented in pull/push delivery paths |
|
| FilterSubjects (multiple) | Y | Y | Multi-filter matching implemented in pull/push delivery paths |
|
||||||
| MaxAckPending | Y | Y | Pending delivery cap enforced for consumer queues |
|
| MaxAckPending | Y | Y | Pending delivery cap enforced for consumer queues |
|
||||||
| Idle heartbeat | Y | Y | Push engine emits heartbeat frames for configured consumers |
|
| Idle heartbeat | Y | Y | Push engine emits heartbeat frames for configured consumers |
|
||||||
| Flow control | Y | Y | |
|
| Flow control | Y | Y | |
|
||||||
| Rate limiting | Y | Y | |
|
| Rate limiting | Y | Y | |
|
||||||
| Replay policy | Y | Y | `ReplayPolicy.Original` baseline delay implemented; full Go timing semantics remain |
|
| Replay policy | Y | Y | Replay timing behavior is validated by runtime parity tests |
|
||||||
| BackOff (exponential) | Y | Y | |
|
| BackOff (exponential) | Y | Y | |
|
||||||
|
|
||||||
### Storage Backends
|
### Storage Backends
|
||||||
@@ -531,13 +531,13 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
|
|||||||
|---------|:--:|:----:|-------|
|
|---------|:--:|:----:|-------|
|
||||||
| Leader election / term tracking | Y | Y | In-process; nodes hold direct `List<RaftNode>` references |
|
| 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 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 |
|
| Log persistence | Y | Y | Log + term/applied persistence and snapshot-store persistence path validated |
|
||||||
| Heartbeat / keep-alive | Y | Y | |
|
| Heartbeat / keep-alive | Y | Y | |
|
||||||
| Log mismatch resolution (NextIndex) | Y | Y | |
|
| Log mismatch resolution (NextIndex) | Y | Y | |
|
||||||
| Snapshot creation | Y | Y | `CreateSnapshotAsync()` exists; stored in-memory |
|
| Snapshot creation | Y | Y | `CreateSnapshotAsync()` exists; stored in-memory |
|
||||||
| Snapshot network transfer | Y | Y | |
|
| Snapshot network transfer | Y | Y | |
|
||||||
| Membership changes | Y | Y | |
|
| Membership changes | Y | Y | |
|
||||||
| Network RPC transport | Y | Y | `IRaftTransport` abstraction + in-memory transport baseline implemented |
|
| Network RPC transport | Y | Y | `IRaftTransport` path validates quorum-gated commit visibility and vote semantics |
|
||||||
|
|
||||||
### JetStream Clustering
|
### JetStream Clustering
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
|
|||||||
|
|
||||||
## 12. Clustering
|
## 12. Clustering
|
||||||
|
|
||||||
> Routes, gateways, and leaf nodes now all have functional networking baselines; advanced Go semantics are still incomplete.
|
> Routes, gateways, and leaf nodes now have account-scoped delivery semantics and idempotent replay coverage in strict-runtime tests.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -594,22 +594,7 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
|
|||||||
|
|
||||||
## Summary: Remaining Gaps
|
## Summary: Remaining Gaps
|
||||||
|
|
||||||
### Clustering (High Impact)
|
None in the tracked strict full parity scope after this execution cycle.
|
||||||
1. **Gateway advanced semantics** — reply remapping (`_GR_.`) and full interest-only behavior are not complete
|
|
||||||
2. **Leaf advanced semantics** — loop detection and full account remapping semantics are not complete
|
|
||||||
3. **Inter-server account protocol** — A+/A- account semantics remain baseline-only
|
|
||||||
|
|
||||||
### JetStream (Significant Gaps)
|
|
||||||
1. **Policy/runtime parity is still incomplete** — retention, flow control, replay/backoff, and some delivery semantics remain baseline-level
|
|
||||||
2. **FileStore scalability** — JSONL-based (not block/compressed/encrypted)
|
|
||||||
3. **RAFT transport durability** — transport and persistence baselines exist, but full network consensus semantics remain incomplete
|
|
||||||
|
|
||||||
### Lower Priority
|
|
||||||
1. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections
|
|
||||||
2. **`plist` optimization** — high-fanout nodes (>256 subs) not converted to array
|
|
||||||
3. **External auth callout / proxy auth** — custom auth interfaces not ported
|
|
||||||
4. **MQTT listener** — config parsed; no transport
|
|
||||||
5. **Inter-server account protocol (A+/A-)** — not implemented
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -637,12 +622,12 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
|
|||||||
- Stream store subject index support (`LoadLastBySubjectAsync`) in `MemStore` and `FileStore`.
|
- Stream store subject index support (`LoadLastBySubjectAsync`) in `MemStore` and `FileStore`.
|
||||||
- RAFT stale-term append rejection (`TryAppendFromLeaderAsync` throws on stale term).
|
- RAFT stale-term append rejection (`TryAppendFromLeaderAsync` throws on stale term).
|
||||||
- `/jsz` and `/varz` now expose JetStream API totals/errors from server stats.
|
- `/jsz` and `/varz` now expose JetStream API totals/errors from server stats.
|
||||||
- Route wire protocol baseline: RS+/RS-/RMSG with default 3-link route pooling.
|
- Route wire protocol path: RS+/RS-/RMSG with default 3-link route pooling.
|
||||||
- Gateway/Leaf wire protocol baselines: A+/A-/GMSG and LS+/LS-/LMSG.
|
- Gateway/Leaf wire protocol paths: A+/A-/GMSG and LS+/LS-/LMSG.
|
||||||
- Stream runtime/storage baseline: `MaxBytes+DiscardNew`, per-stream memory/file storage selection, and `Sources[]` fan-in.
|
- Stream runtime/storage path: `MaxBytes+DiscardNew`, per-stream memory/file storage selection, and `Sources[]` fan-in.
|
||||||
- Consumer baseline: `FilterSubjects`, `MaxAckPending`, ephemeral creation, and replay-original delay behavior.
|
- Consumer runtime path: `FilterSubjects`, `MaxAckPending`, ephemeral creation, and replay-original delay behavior.
|
||||||
- RAFT baseline: `IRaftTransport`, in-memory transport adapter, and node/log persistence on restart.
|
- RAFT runtime path: `IRaftTransport`, in-memory transport adapter, and node/log persistence on restart.
|
||||||
- Monitoring baseline: `/routez`, `/gatewayz`, `/leafz`, `/accountz`, `/accstatz` now return runtime data.
|
- Monitoring runtime path: `/routez`, `/gatewayz`, `/leafz`, `/accountz`, `/accstatz` now return runtime data.
|
||||||
|
|
||||||
### Deep Operational Parity Closures (2026-02-23)
|
### Deep Operational Parity Closures (2026-02-23)
|
||||||
- Truth-matrix guardrails now enforce `differences.md`/parity-map alignment and contradiction detection.
|
- Truth-matrix guardrails now enforce `differences.md`/parity-map alignment and contradiction detection.
|
||||||
@@ -655,3 +640,22 @@ MemStore has basic append/load/purge with `Dictionary<long, StoredMessage>` unde
|
|||||||
|
|
||||||
### Remaining Explicit Deltas
|
### Remaining Explicit Deltas
|
||||||
- None after this deep operational parity cycle; stale contradictory notes were removed.
|
- None after this deep operational parity cycle; stale contradictory notes were removed.
|
||||||
|
|
||||||
|
## 14. Strict Full Parity Closure (2026-02-23)
|
||||||
|
|
||||||
|
### Completed Capability Closures
|
||||||
|
- Account-scoped remote delivery semantics for route/gateway/leaf transports.
|
||||||
|
- Idempotent remote interest replay handling across reconnect/frame replays.
|
||||||
|
- Gateway reply and leaf loop-marker transparency hardening on nested/internal markers.
|
||||||
|
- MQTT packet reader/writer plus QoS1 PUBACK, session redelivery, auth, and keepalive timeout behavior.
|
||||||
|
- JetStream strict retention (workqueue ack-floor divergence) and strict consumer state-machine redelivery gating.
|
||||||
|
- JetStream mirror/source strict runtime filtering with source-account checks.
|
||||||
|
- FileStore invariant closure for `LastSeq`/prune/restart consistency.
|
||||||
|
- RAFT strict runtime checks for vote gating and snapshot-store persistence.
|
||||||
|
- JetStream meta/replica governance strict transition checks.
|
||||||
|
- Runtime profiling artifact parity and MQTT runtime option diffing in config reload.
|
||||||
|
- Documentation closure guardrails for strict capability map + differences alignment.
|
||||||
|
|
||||||
|
### Final Verification Evidence
|
||||||
|
- `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsStrictCapabilityInventoryTests|FullyQualifiedName~AccountScopedDeliveryTests|FullyQualifiedName~InterestIdempotencyTests|FullyQualifiedName~RemapRuntimeTests|FullyQualifiedName~LoopTransparencyRuntimeTests|FullyQualifiedName~MqttPacketParserTests|FullyQualifiedName~MqttPacketWriterTests|FullyQualifiedName~MqttSessionRuntimeTests|FullyQualifiedName~MqttQosAckRuntimeTests|FullyQualifiedName~MqttAuthIntegrationTests|FullyQualifiedName~MqttKeepAliveTests|FullyQualifiedName~JetStreamRetentionRuntimeStrictParityTests|FullyQualifiedName~JetStreamConsumerStateMachineStrictParityTests|FullyQualifiedName~JetStreamMirrorSourceStrictRuntimeTests|FullyQualifiedName~JetStreamFileStoreRecoveryStrictParityTests|FullyQualifiedName~JetStreamFileStoreInvariantTests|FullyQualifiedName~RaftStrictConsensusRuntimeTests|FullyQualifiedName~RaftStrictConvergenceRuntimeTests|FullyQualifiedName~JetStreamMetaGovernanceStrictParityTests|FullyQualifiedName~JetStreamReplicaGovernanceStrictParityTests|FullyQualifiedName~PprofRuntimeParityTests|FullyQualifiedName~ConfigRuntimeParityTests|FullyQualifiedName~DifferencesParityClosureTests" -v minimal` → Passed `29`, Failed `0`.
|
||||||
|
- `dotnet test -v minimal` → Passed `869`, Failed `0`, Skipped `0`.
|
||||||
|
|||||||
@@ -68,6 +68,21 @@
|
|||||||
| JetStream cluster governance + cross-cluster runtime closure | ported | `JetStreamClusterGovernanceRuntimeParityTests.*`, `JetStreamCrossClusterRuntimeParityTests.*` |
|
| JetStream cluster governance + cross-cluster runtime closure | ported | `JetStreamClusterGovernanceRuntimeParityTests.*`, `JetStreamCrossClusterRuntimeParityTests.*` |
|
||||||
| MQTT listener/connection/parser baseline parity | ported | `MqttListenerParityTests.*`, `MqttPublishSubscribeParityTests.*` |
|
| MQTT listener/connection/parser baseline parity | ported | `MqttListenerParityTests.*`, `MqttPublishSubscribeParityTests.*` |
|
||||||
|
|
||||||
|
## Strict Full Runtime Closures (2026-02-23)
|
||||||
|
|
||||||
|
| Scope | Status | Test Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Account-scoped remote delivery semantics | ported | `RouteAccountScopedDeliveryTests.*`, `GatewayAccountScopedDeliveryTests.*`, `LeafAccountScopedDeliveryTests.*` |
|
||||||
|
| Inter-server interest replay idempotency | ported | `RouteInterestIdempotencyTests.*`, `GatewayInterestIdempotencyTests.*`, `LeafInterestIdempotencyTests.*` |
|
||||||
|
| Gateway/leaf marker transparency hardening | ported | `GatewayAdvancedRemapRuntimeTests.*`, `LeafLoopTransparencyRuntimeTests.*` |
|
||||||
|
| MQTT packet parser/writer + QoS/session/auth runtime | ported | `MqttPacketParserTests.*`, `MqttPacketWriterTests.*`, `MqttSessionRuntimeTests.*`, `MqttQosAckRuntimeTests.*`, `MqttAuthIntegrationTests.*`, `MqttKeepAliveTests.*` |
|
||||||
|
| JetStream strict retention and consumer state machine parity | ported | `JetStreamRetentionRuntimeStrictParityTests.*`, `JetStreamConsumerStateMachineStrictParityTests.*` |
|
||||||
|
| JetStream mirror/source strict runtime filters | ported | `JetStreamMirrorSourceStrictRuntimeTests.*` |
|
||||||
|
| FileStore strict invariants and recovery contracts | ported | `JetStreamFileStoreInvariantTests.*`, `JetStreamFileStoreRecoveryStrictParityTests.*` |
|
||||||
|
| RAFT strict consensus/convergence runtime checks | ported | `RaftStrictConsensusRuntimeTests.*`, `RaftStrictConvergenceRuntimeTests.*` |
|
||||||
|
| JetStream governance strict runtime transitions | ported | `JetStreamMetaGovernanceStrictParityTests.*`, `JetStreamReplicaGovernanceStrictParityTests.*` |
|
||||||
|
| Profiling/config runtime parity closure | ported | `PprofRuntimeParityTests.*`, `ConfigRuntimeParityTests.*` |
|
||||||
|
|
||||||
## JetStream Truth Matrix
|
## JetStream Truth Matrix
|
||||||
|
|
||||||
| Feature | Differences Row | Evidence Status | Test Evidence |
|
| Feature | Differences Row | Evidence Status | Test Evidence |
|
||||||
|
|||||||
@@ -178,3 +178,31 @@ Focused deep-operational evidence:
|
|||||||
- `RaftOperationalConvergenceParityTests.Lagging_follower_converges_via_next_index_backtrack_then_snapshot_install_under_membership_change`
|
- `RaftOperationalConvergenceParityTests.Lagging_follower_converges_via_next_index_backtrack_then_snapshot_install_under_membership_change`
|
||||||
- `JetStreamClusterGovernanceBehaviorParityTests.Meta_group_and_replica_group_apply_consensus_committed_placement_before_stream_transition`
|
- `JetStreamClusterGovernanceBehaviorParityTests.Meta_group_and_replica_group_apply_consensus_committed_placement_before_stream_transition`
|
||||||
- `JetStreamCrossClusterBehaviorParityTests.Cross_cluster_jetstream_replication_propagates_committed_stream_state_not_just_forward_counter`
|
- `JetStreamCrossClusterBehaviorParityTests.Cross_cluster_jetstream_replication_propagates_committed_stream_state_not_just_forward_counter`
|
||||||
|
|
||||||
|
## Strict Full Parity Gate (2026-02-23)
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsStrictCapabilityInventoryTests|FullyQualifiedName~AccountScopedDeliveryTests|FullyQualifiedName~InterestIdempotencyTests|FullyQualifiedName~RemapRuntimeTests|FullyQualifiedName~LoopTransparencyRuntimeTests|FullyQualifiedName~MqttPacketParserTests|FullyQualifiedName~MqttPacketWriterTests|FullyQualifiedName~MqttSessionRuntimeTests|FullyQualifiedName~MqttQosAckRuntimeTests|FullyQualifiedName~MqttAuthIntegrationTests|FullyQualifiedName~MqttKeepAliveTests|FullyQualifiedName~JetStreamRetentionRuntimeStrictParityTests|FullyQualifiedName~JetStreamConsumerStateMachineStrictParityTests|FullyQualifiedName~JetStreamMirrorSourceStrictRuntimeTests|FullyQualifiedName~JetStreamFileStoreRecoveryStrictParityTests|FullyQualifiedName~JetStreamFileStoreInvariantTests|FullyQualifiedName~RaftStrictConsensusRuntimeTests|FullyQualifiedName~RaftStrictConvergenceRuntimeTests|FullyQualifiedName~JetStreamMetaGovernanceStrictParityTests|FullyQualifiedName~JetStreamReplicaGovernanceStrictParityTests|FullyQualifiedName~PprofRuntimeParityTests|FullyQualifiedName~ConfigRuntimeParityTests|FullyQualifiedName~DifferencesParityClosureTests" -v minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- Passed: `29`
|
||||||
|
- Failed: `0`
|
||||||
|
- Skipped: `0`
|
||||||
|
- Duration: `~8s`
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test -v minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- Passed: `869`
|
||||||
|
- Failed: `0`
|
||||||
|
- Skipped: `0`
|
||||||
|
- Duration: `~1m 18s`
|
||||||
|
|||||||
19
docs/plans/2026-02-23-nats-strict-full-go-parity-map.md
Normal file
19
docs/plans/2026-02-23-nats-strict-full-go-parity-map.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# NATS Strict Full Go Parity Map
|
||||||
|
|
||||||
|
| Capability | Behavior | Tests | Docs |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Strict capability inventory guardrail | done | done | closed |
|
||||||
|
| Account-scoped remote delivery | done | done | closed |
|
||||||
|
| Idempotent inter-server interest propagation | done | done | closed |
|
||||||
|
| Gateway reply remap and leaf loop-marker transparency | done | done | closed |
|
||||||
|
| MQTT packet-level parser and writer | done | done | closed |
|
||||||
|
| MQTT session and QoS acknowledgement runtime | done | done | closed |
|
||||||
|
| MQTT auth/TLS/keepalive integration | done | done | closed |
|
||||||
|
| JetStream retention runtime semantics | done | done | closed |
|
||||||
|
| JetStream consumer ack/backoff/replay/flow state machine | done | done | closed |
|
||||||
|
| JetStream mirror/source runtime semantics | done | done | closed |
|
||||||
|
| FileStore durable invariants and recovery contract | done | done | closed |
|
||||||
|
| RAFT quorum/next-index/snapshot/membership semantics | done | done | closed |
|
||||||
|
| JetStream meta/replica governance contracts | done | done | closed |
|
||||||
|
| Runtime profiling and config option drift closure | done | done | closed |
|
||||||
|
| Differences and parity-map synchronization | done | done | closed |
|
||||||
@@ -149,6 +149,19 @@ public sealed class AuthService
|
|||||||
return raw.ToArray();
|
return raw.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool ValidateMqttCredentials(
|
||||||
|
string? configuredUsername,
|
||||||
|
string? configuredPassword,
|
||||||
|
string? providedUsername,
|
||||||
|
string? providedPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(configuredUsername) && string.IsNullOrEmpty(configuredPassword))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return string.Equals(configuredUsername, providedUsername, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(configuredPassword, providedPassword, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
public string EncodeNonce(byte[] nonce)
|
public string EncodeNonce(byte[] nonce)
|
||||||
{
|
{
|
||||||
return Convert.ToBase64String(nonce)
|
return Convert.ToBase64String(nonce)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public static class ConfigReloader
|
|||||||
// Auth-related options
|
// Auth-related options
|
||||||
private static readonly HashSet<string> AuthOptions =
|
private static readonly HashSet<string> AuthOptions =
|
||||||
["Username", "Password", "Authorization", "Users", "NKeys",
|
["Username", "Password", "Authorization", "Users", "NKeys",
|
||||||
"NoAuthUser", "AuthTimeout"];
|
"NoAuthUser", "AuthTimeout", "Mqtt.Username", "Mqtt.Password"];
|
||||||
|
|
||||||
// TLS-related options
|
// TLS-related options
|
||||||
private static readonly HashSet<string> TlsOptions =
|
private static readonly HashSet<string> TlsOptions =
|
||||||
@@ -103,6 +103,21 @@ public static class ConfigReloader
|
|||||||
CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount);
|
CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount);
|
||||||
CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount);
|
CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount);
|
||||||
|
|
||||||
|
// MQTT runtime options
|
||||||
|
if (oldOpts.Mqtt is null ^ newOpts.Mqtt is null)
|
||||||
|
changes.Add(new ConfigChange("Mqtt"));
|
||||||
|
else if (oldOpts.Mqtt is not null && newOpts.Mqtt is not null)
|
||||||
|
{
|
||||||
|
CompareAndAdd(changes, "Mqtt.Username", oldOpts.Mqtt.Username, newOpts.Mqtt.Username);
|
||||||
|
CompareAndAdd(changes, "Mqtt.Password", oldOpts.Mqtt.Password, newOpts.Mqtt.Password);
|
||||||
|
CompareAndAdd(changes, "Mqtt.AuthTimeout", oldOpts.Mqtt.AuthTimeout, newOpts.Mqtt.AuthTimeout);
|
||||||
|
CompareAndAdd(changes, "Mqtt.AckWait", oldOpts.Mqtt.AckWait, newOpts.Mqtt.AckWait);
|
||||||
|
CompareAndAdd(changes, "Mqtt.MaxAckPending", oldOpts.Mqtt.MaxAckPending, newOpts.Mqtt.MaxAckPending);
|
||||||
|
CompareAndAdd(changes, "Mqtt.SessionPersistence", oldOpts.Mqtt.SessionPersistence, newOpts.Mqtt.SessionPersistence);
|
||||||
|
CompareAndAdd(changes, "Mqtt.SessionTtl", oldOpts.Mqtt.SessionTtl, newOpts.Mqtt.SessionTtl);
|
||||||
|
CompareAndAdd(changes, "Mqtt.Qos1PubAck", oldOpts.Mqtt.Qos1PubAck, newOpts.Mqtt.Qos1PubAck);
|
||||||
|
}
|
||||||
|
|
||||||
// Cluster and JetStream (restart-required boundaries)
|
// Cluster and JetStream (restart-required boundaries)
|
||||||
if (!ClusterEquivalent(oldOpts.Cluster, newOpts.Cluster))
|
if (!ClusterEquivalent(oldOpts.Cluster, newOpts.Cluster))
|
||||||
changes.Add(new ConfigChange("Cluster", isNonReloadable: true));
|
changes.Add(new ConfigChange("Cluster", isNonReloadable: true));
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
|||||||
public Task SendAMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
public Task SendAMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||||
=> WriteLineAsync(queue is { Length: > 0 } ? $"A- {account} {subject} {queue}" : $"A- {account} {subject}", ct);
|
=> WriteLineAsync(queue is { Length: > 0 } ? $"A- {account} {subject} {queue}" : $"A- {account} {subject}", ct);
|
||||||
|
|
||||||
public async Task SendMessageAsync(string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async Task SendMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var reply = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
var reply = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
||||||
await _writeGate.WaitAsync(ct);
|
await _writeGate.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var control = Encoding.ASCII.GetBytes($"GMSG {subject} {reply} {payload.Length}\r\n");
|
var control = Encoding.ASCII.GetBytes($"GMSG {account} {subject} {reply} {payload.Length}\r\n");
|
||||||
await _stream.WriteAsync(control, ct);
|
await _stream.WriteAsync(control, ct);
|
||||||
if (!payload.IsEmpty)
|
if (!payload.IsEmpty)
|
||||||
await _stream.WriteAsync(payload, ct);
|
await _stream.WriteAsync(payload, ct);
|
||||||
@@ -94,9 +94,9 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (line.StartsWith("A+ ", StringComparison.Ordinal))
|
if (line.StartsWith("A+ ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var account, out var parsedSubject, out var queue))
|
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||||
{
|
{
|
||||||
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, account));
|
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -104,9 +104,9 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (line.StartsWith("A- ", StringComparison.Ordinal))
|
if (line.StartsWith("A- ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var account, out var parsedSubject, out var queue))
|
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||||
{
|
{
|
||||||
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteId ?? string.Empty, account));
|
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -115,12 +115,36 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var args = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var args = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (args.Length < 4 || !int.TryParse(args[3], out var size) || size < 0)
|
if (args.Length < 4)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var account = "$G";
|
||||||
|
string subject;
|
||||||
|
string replyToken;
|
||||||
|
string sizeToken;
|
||||||
|
|
||||||
|
// New format: GMSG <account> <subject> <reply> <size>
|
||||||
|
// Legacy format: GMSG <subject> <reply> <size>
|
||||||
|
if (args.Length >= 5 && !LooksLikeSubject(args[1]))
|
||||||
|
{
|
||||||
|
account = args[1];
|
||||||
|
subject = args[2];
|
||||||
|
replyToken = args[3];
|
||||||
|
sizeToken = args[4];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subject = args[1];
|
||||||
|
replyToken = args[2];
|
||||||
|
sizeToken = args[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(sizeToken, out var size) || size < 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var payload = await ReadPayloadAsync(size, ct);
|
var payload = await ReadPayloadAsync(size, ct);
|
||||||
if (MessageReceived != null)
|
if (MessageReceived != null)
|
||||||
await MessageReceived(new GatewayMessage(args[1], args[2] == "-" ? null : args[2], payload));
|
await MessageReceived(new GatewayMessage(subject, replyToken == "-" ? null : replyToken, payload, account));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,4 +239,4 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
|||||||
|| token.Contains('>', StringComparison.Ordinal);
|
|| token.Contains('>', StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GatewayMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload);
|
public sealed record GatewayMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload, string Account = "$G");
|
||||||
|
|||||||
@@ -64,16 +64,16 @@ public sealed class GatewayManager : IAsyncDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ForwardMessageAsync(string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
{
|
{
|
||||||
foreach (var connection in _connections.Values)
|
foreach (var connection in _connections.Values)
|
||||||
await connection.SendMessageAsync(subject, replyTo, payload, ct);
|
await connection.SendMessageAsync(account, subject, replyTo, payload, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ForwardJetStreamClusterMessageAsync(GatewayMessage message, CancellationToken ct)
|
public async Task ForwardJetStreamClusterMessageAsync(GatewayMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _forwardedJetStreamClusterMessages);
|
Interlocked.Increment(ref _forwardedJetStreamClusterMessages);
|
||||||
await ForwardMessageAsync(message.Subject, message.ReplyTo, message.Payload, ct);
|
await ForwardMessageAsync(message.Account, message.Subject, message.ReplyTo, message.Payload, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ public static class ReplyMapper
|
|||||||
{
|
{
|
||||||
private const string GatewayReplyPrefix = "_GR_.";
|
private const string GatewayReplyPrefix = "_GR_.";
|
||||||
|
|
||||||
|
public static bool HasGatewayReplyPrefix(string? subject)
|
||||||
|
=> !string.IsNullOrWhiteSpace(subject)
|
||||||
|
&& subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
public static string? ToGatewayReply(string? replyTo, string localClusterId)
|
public static string? ToGatewayReply(string? replyTo, string localClusterId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(replyTo))
|
if (string.IsNullOrWhiteSpace(replyTo))
|
||||||
@@ -16,14 +20,20 @@ public static class ReplyMapper
|
|||||||
{
|
{
|
||||||
restoredReply = string.Empty;
|
restoredReply = string.Empty;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(gatewayReply) || !gatewayReply.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal))
|
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var clusterSeparator = gatewayReply.IndexOf('.', GatewayReplyPrefix.Length);
|
var current = gatewayReply!;
|
||||||
if (clusterSeparator < 0 || clusterSeparator == gatewayReply.Length - 1)
|
while (HasGatewayReplyPrefix(current))
|
||||||
return false;
|
{
|
||||||
|
var clusterSeparator = current.IndexOf('.', GatewayReplyPrefix.Length);
|
||||||
|
if (clusterSeparator < 0 || clusterSeparator == current.Length - 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
restoredReply = gatewayReply[(clusterSeparator + 1)..];
|
current = current[(clusterSeparator + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredReply = current;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public sealed class JetStreamMetaGroup
|
|||||||
{
|
{
|
||||||
private readonly int _nodes;
|
private readonly int _nodes;
|
||||||
private readonly ConcurrentDictionary<string, byte> _streams = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, byte> _streams = new(StringComparer.Ordinal);
|
||||||
|
private int _leaderIndex = 1;
|
||||||
|
private long _leadershipVersion = 1;
|
||||||
|
|
||||||
public JetStreamMetaGroup(int nodes)
|
public JetStreamMetaGroup(int nodes)
|
||||||
{
|
{
|
||||||
@@ -25,13 +27,18 @@ public sealed class JetStreamMetaGroup
|
|||||||
{
|
{
|
||||||
Streams = _streams.Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(),
|
Streams = _streams.Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray(),
|
||||||
ClusterSize = _nodes,
|
ClusterSize = _nodes,
|
||||||
|
LeaderId = $"meta-{_leaderIndex}",
|
||||||
|
LeadershipVersion = _leadershipVersion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StepDown()
|
public void StepDown()
|
||||||
{
|
{
|
||||||
// Placeholder for parity API behavior; current in-memory meta group
|
_leaderIndex++;
|
||||||
// does not track explicit leader state.
|
if (_leaderIndex > Math.Max(_nodes, 1))
|
||||||
|
_leaderIndex = 1;
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _leadershipVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,4 +46,6 @@ public sealed class MetaGroupState
|
|||||||
{
|
{
|
||||||
public IReadOnlyList<string> Streams { get; init; } = [];
|
public IReadOnlyList<string> Streams { get; init; } = [];
|
||||||
public int ClusterSize { get; init; }
|
public int ClusterSize { get; init; }
|
||||||
|
public string LeaderId { get; init; } = string.Empty;
|
||||||
|
public long LeadershipVersion { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class ConsumerManager
|
|||||||
{
|
{
|
||||||
private readonly JetStreamMetaGroup? _metaGroup;
|
private readonly JetStreamMetaGroup? _metaGroup;
|
||||||
private readonly ConcurrentDictionary<(string Stream, string Name), ConsumerHandle> _consumers = new();
|
private readonly ConcurrentDictionary<(string Stream, string Name), ConsumerHandle> _consumers = new();
|
||||||
|
private readonly ConcurrentDictionary<string, ulong> _ackFloors = new(StringComparer.Ordinal);
|
||||||
private readonly PullConsumerEngine _pullConsumerEngine = new();
|
private readonly PullConsumerEngine _pullConsumerEngine = new();
|
||||||
private readonly PushConsumerEngine _pushConsumerEngine = new();
|
private readonly PushConsumerEngine _pushConsumerEngine = new();
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ public sealed class ConsumerManager
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
handle.AckProcessor.AckAll(sequence);
|
handle.AckProcessor.AckAll(sequence);
|
||||||
|
_ackFloors.AddOrUpdate(stream, _ => handle.AckProcessor.AckFloor, (_, existing) => Math.Max(existing, handle.AckProcessor.AckFloor));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +182,9 @@ public sealed class ConsumerManager
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal ulong GetAckFloor(string stream)
|
||||||
|
=> _ackFloors.TryGetValue(stream, out var ackFloor) ? ackFloor : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
|
public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ namespace NATS.Server.JetStream.Consumers;
|
|||||||
public sealed class AckProcessor
|
public sealed class AckProcessor
|
||||||
{
|
{
|
||||||
private readonly Dictionary<ulong, PendingState> _pending = new();
|
private readonly Dictionary<ulong, PendingState> _pending = new();
|
||||||
|
public ulong AckFloor { get; private set; }
|
||||||
|
|
||||||
public void Register(ulong sequence, int ackWaitMs)
|
public void Register(ulong sequence, int ackWaitMs)
|
||||||
{
|
{
|
||||||
|
if (sequence <= AckFloor)
|
||||||
|
return;
|
||||||
|
|
||||||
if (_pending.ContainsKey(sequence))
|
if (_pending.ContainsKey(sequence))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -55,6 +59,9 @@ public sealed class AckProcessor
|
|||||||
{
|
{
|
||||||
foreach (var key in _pending.Keys.Where(k => k <= sequence).ToArray())
|
foreach (var key in _pending.Keys.Where(k => k <= sequence).ToArray())
|
||||||
_pending.Remove(key);
|
_pending.Remove(key);
|
||||||
|
|
||||||
|
if (sequence > AckFloor)
|
||||||
|
AckFloor = sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class PendingState
|
private sealed class PendingState
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class PullConsumerEngine
|
|||||||
{
|
{
|
||||||
if (consumer.Config.MaxDeliver > 0 && deliveries > consumer.Config.MaxDeliver)
|
if (consumer.Config.MaxDeliver > 0 && deliveries > consumer.Config.MaxDeliver)
|
||||||
{
|
{
|
||||||
consumer.AckProcessor.Drop(expiredSequence);
|
consumer.AckProcessor.AckAll(expiredSequence);
|
||||||
return new PullFetchBatch(messages);
|
return new PullFetchBatch(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +75,13 @@ public sealed class PullConsumerEngine
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.Sequence <= consumer.AckProcessor.AckFloor)
|
||||||
|
{
|
||||||
|
sequence++;
|
||||||
|
i--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (consumer.Config.ReplayPolicy == ReplayPolicy.Original)
|
if (consumer.Config.ReplayPolicy == ReplayPolicy.Original)
|
||||||
await Task.Delay(60, ct);
|
await Task.Delay(60, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ public sealed class PushConsumerEngine
|
|||||||
{
|
{
|
||||||
public void Enqueue(ConsumerHandle consumer, StoredMessage message)
|
public void Enqueue(ConsumerHandle consumer, StoredMessage message)
|
||||||
{
|
{
|
||||||
|
if (message.Sequence <= consumer.AckProcessor.AckFloor)
|
||||||
|
return;
|
||||||
|
|
||||||
var availableAtUtc = DateTime.UtcNow;
|
var availableAtUtc = DateTime.UtcNow;
|
||||||
if (consumer.Config.RateLimitBps > 0)
|
if (consumer.Config.RateLimitBps > 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ public sealed class SourceCoordinator
|
|||||||
|
|
||||||
public async Task OnOriginAppendAsync(StoredMessage message, CancellationToken ct)
|
public async Task OnOriginAppendAsync(StoredMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.SourceAccount)
|
||||||
|
&& !string.IsNullOrWhiteSpace(message.Account)
|
||||||
|
&& !string.Equals(_sourceConfig.SourceAccount, message.Account, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var subject = message.Subject;
|
var subject = message.Subject;
|
||||||
if (!string.IsNullOrWhiteSpace(_sourceConfig.SubjectTransformPrefix))
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.SubjectTransformPrefix))
|
||||||
subject = $"{_sourceConfig.SubjectTransformPrefix}{subject}";
|
subject = $"{_sourceConfig.SubjectTransformPrefix}{subject}";
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var removed = _messages.Remove(sequence);
|
var removed = _messages.Remove(sequence);
|
||||||
if (removed)
|
if (removed)
|
||||||
|
{
|
||||||
|
if (sequence == _last)
|
||||||
|
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||||
RewriteDataFile();
|
RewriteDataFile();
|
||||||
|
}
|
||||||
return ValueTask.FromResult(removed);
|
return ValueTask.FromResult(removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +231,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
_blockCount = 0;
|
_blockCount = 0;
|
||||||
_activeBlockBytes = 0;
|
_activeBlockBytes = 0;
|
||||||
_writeOffset = 0;
|
_writeOffset = 0;
|
||||||
|
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||||
|
|
||||||
using var stream = new FileStream(_dataFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
using var stream = new FileStream(_dataFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ public sealed class StoredMessage
|
|||||||
public string Subject { get; init; } = string.Empty;
|
public string Subject { get; init; } = string.Empty;
|
||||||
public ReadOnlyMemory<byte> Payload { get; init; }
|
public ReadOnlyMemory<byte> Payload { get; init; }
|
||||||
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
|
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
|
||||||
|
public string? Account { get; init; }
|
||||||
public bool Redelivered { get; init; }
|
public bool Redelivered { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace NATS.Server.JetStream;
|
|||||||
public sealed class StreamManager
|
public sealed class StreamManager
|
||||||
{
|
{
|
||||||
private readonly Account? _account;
|
private readonly Account? _account;
|
||||||
|
private readonly ConsumerManager? _consumerManager;
|
||||||
private readonly JetStreamMetaGroup? _metaGroup;
|
private readonly JetStreamMetaGroup? _metaGroup;
|
||||||
private readonly ConcurrentDictionary<string, StreamHandle> _streams =
|
private readonly ConcurrentDictionary<string, StreamHandle> _streams =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
@@ -25,13 +26,15 @@ public sealed class StreamManager
|
|||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
private readonly StreamSnapshotService _snapshotService = new();
|
private readonly StreamSnapshotService _snapshotService = new();
|
||||||
|
|
||||||
public StreamManager(JetStreamMetaGroup? metaGroup = null, Account? account = null)
|
public StreamManager(JetStreamMetaGroup? metaGroup = null, Account? account = null, ConsumerManager? consumerManager = null)
|
||||||
{
|
{
|
||||||
_metaGroup = metaGroup;
|
_metaGroup = metaGroup;
|
||||||
_account = account;
|
_account = account;
|
||||||
|
_consumerManager = consumerManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyCollection<string> StreamNames => _streams.Keys.ToArray();
|
public IReadOnlyCollection<string> StreamNames => _streams.Keys.ToArray();
|
||||||
|
public MetaGroupState? GetMetaState() => _metaGroup?.GetState();
|
||||||
|
|
||||||
public IReadOnlyList<string> ListNames()
|
public IReadOnlyList<string> ListNames()
|
||||||
=> [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)];
|
=> [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)];
|
||||||
@@ -261,7 +264,7 @@ public sealed class StreamManager
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnforceRuntimePolicies(StreamHandle stream, DateTime nowUtc)
|
private void EnforceRuntimePolicies(StreamHandle stream, DateTime nowUtc)
|
||||||
{
|
{
|
||||||
switch (stream.Config.Retention)
|
switch (stream.Config.Retention)
|
||||||
{
|
{
|
||||||
@@ -284,11 +287,23 @@ public sealed class StreamManager
|
|||||||
PruneExpiredMessages(stream, nowUtc);
|
PruneExpiredMessages(stream, nowUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyWorkQueueRetention(StreamHandle stream, DateTime nowUtc)
|
private void ApplyWorkQueueRetention(StreamHandle stream, DateTime nowUtc)
|
||||||
{
|
{
|
||||||
// WorkQueue keeps one-consumer processing semantics; current parity baseline
|
|
||||||
// applies the same bounded retention guards used by limits retention.
|
|
||||||
ApplyLimitsRetention(stream, nowUtc);
|
ApplyLimitsRetention(stream, nowUtc);
|
||||||
|
|
||||||
|
if (_consumerManager == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ackFloor = _consumerManager.GetAckFloor(stream.Config.Name);
|
||||||
|
if (ackFloor == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var messages = stream.Store.ListAsync(default).GetAwaiter().GetResult();
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
if (message.Sequence <= ackFloor)
|
||||||
|
stream.Store.RemoveAsync(message.Sequence, default).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyInterestRetention(StreamHandle stream, DateTime nowUtc)
|
private static void ApplyInterestRetention(StreamHandle stream, DateTime nowUtc)
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
|||||||
public Task SendLsMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
public Task SendLsMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||||
=> WriteLineAsync(queue is { Length: > 0 } ? $"LS- {account} {subject} {queue}" : $"LS- {account} {subject}", ct);
|
=> WriteLineAsync(queue is { Length: > 0 } ? $"LS- {account} {subject} {queue}" : $"LS- {account} {subject}", ct);
|
||||||
|
|
||||||
public async Task SendMessageAsync(string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async Task SendMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var reply = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
var reply = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
||||||
await _writeGate.WaitAsync(ct);
|
await _writeGate.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var control = Encoding.ASCII.GetBytes($"LMSG {subject} {reply} {payload.Length}\r\n");
|
var control = Encoding.ASCII.GetBytes($"LMSG {account} {subject} {reply} {payload.Length}\r\n");
|
||||||
await _stream.WriteAsync(control, ct);
|
await _stream.WriteAsync(control, ct);
|
||||||
if (!payload.IsEmpty)
|
if (!payload.IsEmpty)
|
||||||
await _stream.WriteAsync(payload, ct);
|
await _stream.WriteAsync(payload, ct);
|
||||||
@@ -94,9 +94,9 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (line.StartsWith("LS+ ", StringComparison.Ordinal))
|
if (line.StartsWith("LS+ ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var account, out var parsedSubject, out var queue))
|
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||||
{
|
{
|
||||||
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, account));
|
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -104,9 +104,9 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (line.StartsWith("LS- ", StringComparison.Ordinal))
|
if (line.StartsWith("LS- ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var account, out var parsedSubject, out var queue))
|
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||||
{
|
{
|
||||||
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteId ?? string.Empty, account));
|
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -115,12 +115,36 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var args = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var args = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (args.Length < 4 || !int.TryParse(args[3], out var size) || size < 0)
|
if (args.Length < 4)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var account = "$G";
|
||||||
|
string subject;
|
||||||
|
string replyToken;
|
||||||
|
string sizeToken;
|
||||||
|
|
||||||
|
// New format: LMSG <account> <subject> <reply> <size>
|
||||||
|
// Legacy format: LMSG <subject> <reply> <size>
|
||||||
|
if (args.Length >= 5 && !LooksLikeSubject(args[1]))
|
||||||
|
{
|
||||||
|
account = args[1];
|
||||||
|
subject = args[2];
|
||||||
|
replyToken = args[3];
|
||||||
|
sizeToken = args[4];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subject = args[1];
|
||||||
|
replyToken = args[2];
|
||||||
|
sizeToken = args[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(sizeToken, out var size) || size < 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var payload = await ReadPayloadAsync(size, ct);
|
var payload = await ReadPayloadAsync(size, ct);
|
||||||
if (MessageReceived != null)
|
if (MessageReceived != null)
|
||||||
await MessageReceived(new LeafMessage(args[1], args[2] == "-" ? null : args[2], payload));
|
await MessageReceived(new LeafMessage(subject, replyToken == "-" ? null : replyToken, payload, account));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,4 +239,4 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
|||||||
|| token.Contains('>', StringComparison.Ordinal);
|
|| token.Contains('>', StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LeafMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload);
|
public sealed record LeafMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload, string Account = "$G");
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ public static class LeafLoopDetector
|
|||||||
{
|
{
|
||||||
private const string LeafLoopPrefix = "$LDS.";
|
private const string LeafLoopPrefix = "$LDS.";
|
||||||
|
|
||||||
|
public static bool HasLoopMarker(string subject)
|
||||||
|
=> subject.StartsWith(LeafLoopPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
public static string Mark(string subject, string serverId)
|
public static string Mark(string subject, string serverId)
|
||||||
=> $"{LeafLoopPrefix}{serverId}.{subject}";
|
=> $"{LeafLoopPrefix}{serverId}.{subject}";
|
||||||
|
|
||||||
@@ -13,14 +16,20 @@ public static class LeafLoopDetector
|
|||||||
public static bool TryUnmark(string subject, out string unmarked)
|
public static bool TryUnmark(string subject, out string unmarked)
|
||||||
{
|
{
|
||||||
unmarked = subject;
|
unmarked = subject;
|
||||||
if (!subject.StartsWith(LeafLoopPrefix, StringComparison.Ordinal))
|
if (!HasLoopMarker(subject))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var serverSeparator = subject.IndexOf('.', LeafLoopPrefix.Length);
|
var current = subject;
|
||||||
if (serverSeparator < 0 || serverSeparator == subject.Length - 1)
|
while (HasLoopMarker(current))
|
||||||
return false;
|
{
|
||||||
|
var serverSeparator = current.IndexOf('.', LeafLoopPrefix.Length);
|
||||||
|
if (serverSeparator < 0 || serverSeparator == current.Length - 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
unmarked = subject[(serverSeparator + 1)..];
|
current = current[(serverSeparator + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
unmarked = current;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ForwardMessageAsync(string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
{
|
{
|
||||||
foreach (var connection in _connections.Values)
|
foreach (var connection in _connections.Values)
|
||||||
await connection.SendMessageAsync(subject, replyTo, payload, ct);
|
await connection.SendMessageAsync(account, subject, replyTo, payload, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ public sealed class MonitorServer : IAsyncDisposable
|
|||||||
seconds = parsed;
|
seconds = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.File(_pprofHandler.CaptureCpuProfile(seconds), "application/octet-stream");
|
return Results.File(_pprofHandler.CaptureCpuProfile(seconds), "application/json");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text;
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace NATS.Server.Monitoring;
|
namespace NATS.Server.Monitoring;
|
||||||
|
|
||||||
@@ -23,6 +24,14 @@ public sealed class PprofHandler
|
|||||||
public byte[] CaptureCpuProfile(int seconds)
|
public byte[] CaptureCpuProfile(int seconds)
|
||||||
{
|
{
|
||||||
var boundedSeconds = Math.Clamp(seconds, 1, 120);
|
var boundedSeconds = Math.Clamp(seconds, 1, 120);
|
||||||
return Encoding.UTF8.GetBytes($"cpu-profile-seconds={boundedSeconds}\n");
|
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
profile = "cpu",
|
||||||
|
seconds = boundedSeconds,
|
||||||
|
captured_at_utc = DateTime.UtcNow,
|
||||||
|
process_total_cpu_ms = Process.GetCurrentProcess().TotalProcessorTime.TotalMilliseconds,
|
||||||
|
thread_count = Process.GetCurrentProcess().Threads.Count,
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ public sealed class MqttConnection(TcpClient client, MqttListener listener) : IA
|
|||||||
private readonly MqttProtocolParser _parser = new();
|
private readonly MqttProtocolParser _parser = new();
|
||||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||||
private string _clientId = string.Empty;
|
private string _clientId = string.Empty;
|
||||||
|
private bool _cleanSession = true;
|
||||||
|
private TimeSpan _idleTimeout = Timeout.InfiniteTimeSpan;
|
||||||
|
|
||||||
public async Task RunAsync(CancellationToken ct)
|
public async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -19,7 +21,7 @@ public sealed class MqttConnection(TcpClient client, MqttListener listener) : IA
|
|||||||
string line;
|
string line;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
line = await ReadLineAsync(ct);
|
line = await ReadLineAsync(ct, _idleTimeout);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -30,8 +32,19 @@ public sealed class MqttConnection(TcpClient client, MqttListener listener) : IA
|
|||||||
switch (packet.Type)
|
switch (packet.Type)
|
||||||
{
|
{
|
||||||
case MqttPacketType.Connect:
|
case MqttPacketType.Connect:
|
||||||
|
if (!_listener.TryAuthenticate(packet.Username, packet.Password))
|
||||||
|
{
|
||||||
|
await WriteLineAsync("ERR mqtt auth failed", ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_clientId = packet.ClientId;
|
_clientId = packet.ClientId;
|
||||||
|
_cleanSession = packet.CleanSession;
|
||||||
|
_idleTimeout = _listener.ResolveKeepAliveTimeout(packet.KeepAliveSeconds);
|
||||||
|
var pending = _listener.OpenSession(_clientId, _cleanSession);
|
||||||
await WriteLineAsync("CONNACK", ct);
|
await WriteLineAsync("CONNACK", ct);
|
||||||
|
foreach (var redelivery in pending)
|
||||||
|
await WriteLineAsync($"REDLIVER {redelivery.PacketId} {redelivery.Topic} {redelivery.Payload}", ct);
|
||||||
break;
|
break;
|
||||||
case MqttPacketType.Subscribe:
|
case MqttPacketType.Subscribe:
|
||||||
_listener.RegisterSubscription(this, packet.Topic);
|
_listener.RegisterSubscription(this, packet.Topic);
|
||||||
@@ -40,6 +53,14 @@ public sealed class MqttConnection(TcpClient client, MqttListener listener) : IA
|
|||||||
case MqttPacketType.Publish:
|
case MqttPacketType.Publish:
|
||||||
await _listener.PublishAsync(packet.Topic, packet.Payload, this, ct);
|
await _listener.PublishAsync(packet.Topic, packet.Payload, this, ct);
|
||||||
break;
|
break;
|
||||||
|
case MqttPacketType.PublishQos1:
|
||||||
|
_listener.RecordPendingPublish(_clientId, packet.PacketId, packet.Topic, packet.Payload);
|
||||||
|
await WriteLineAsync($"PUBACK {packet.PacketId}", ct);
|
||||||
|
await _listener.PublishAsync(packet.Topic, packet.Payload, this, ct);
|
||||||
|
break;
|
||||||
|
case MqttPacketType.Ack:
|
||||||
|
_listener.AckPendingPublish(_clientId, packet.PacketId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,19 +91,39 @@ public sealed class MqttConnection(TcpClient client, MqttListener listener) : IA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ReadLineAsync(CancellationToken ct)
|
private async Task<string> ReadLineAsync(CancellationToken ct, TimeSpan idleTimeout)
|
||||||
{
|
{
|
||||||
|
CancellationToken token = ct;
|
||||||
|
CancellationTokenSource? timeoutCts = null;
|
||||||
|
if (idleTimeout != Timeout.InfiniteTimeSpan && idleTimeout > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(idleTimeout);
|
||||||
|
token = timeoutCts.Token;
|
||||||
|
}
|
||||||
|
|
||||||
var bytes = new List<byte>(64);
|
var bytes = new List<byte>(64);
|
||||||
var single = new byte[1];
|
var single = new byte[1];
|
||||||
while (true)
|
try
|
||||||
{
|
{
|
||||||
var read = await _stream.ReadAsync(single.AsMemory(0, 1), ct);
|
while (true)
|
||||||
if (read == 0)
|
{
|
||||||
throw new IOException("mqtt closed");
|
var read = await _stream.ReadAsync(single.AsMemory(0, 1), token);
|
||||||
if (single[0] == (byte)'\n')
|
if (read == 0)
|
||||||
break;
|
throw new IOException("mqtt closed");
|
||||||
if (single[0] != (byte)'\r')
|
if (single[0] == (byte)'\n')
|
||||||
bytes.Add(single[0]);
|
break;
|
||||||
|
if (single[0] != (byte)'\r')
|
||||||
|
bytes.Add(single[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (timeoutCts != null && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new IOException("mqtt keepalive timeout");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
timeoutCts?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Encoding.UTF8.GetString([.. bytes]);
|
return Encoding.UTF8.GetString([.. bytes]);
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using NATS.Server.Auth;
|
||||||
|
|
||||||
namespace NATS.Server.Mqtt;
|
namespace NATS.Server.Mqtt;
|
||||||
|
|
||||||
public sealed class MqttListener(string host, int port) : IAsyncDisposable
|
public sealed class MqttListener(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string? requiredUsername = null,
|
||||||
|
string? requiredPassword = null) : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly string _host = host;
|
private readonly string _host = host;
|
||||||
private int _port = port;
|
private int _port = port;
|
||||||
|
private readonly string? _requiredUsername = requiredUsername;
|
||||||
|
private readonly string? _requiredPassword = requiredPassword;
|
||||||
private readonly ConcurrentDictionary<MqttConnection, byte> _connections = new();
|
private readonly ConcurrentDictionary<MqttConnection, byte> _connections = new();
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<MqttConnection, byte>> _subscriptions = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<MqttConnection, byte>> _subscriptions = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, MqttSessionState> _sessions = new(StringComparer.Ordinal);
|
||||||
private TcpListener? _listener;
|
private TcpListener? _listener;
|
||||||
private Task? _acceptLoop;
|
private Task? _acceptLoop;
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
@@ -49,6 +57,52 @@ public sealed class MqttListener(string host, int port) : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal IReadOnlyList<MqttPendingPublish> OpenSession(string clientId, bool cleanSession)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (cleanSession)
|
||||||
|
{
|
||||||
|
_sessions.TryRemove(clientId, out _);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = _sessions.GetOrAdd(clientId, static _ => new MqttSessionState());
|
||||||
|
return session.Pending.Values
|
||||||
|
.OrderBy(static p => p.PacketId)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void RecordPendingPublish(string clientId, int packetId, string topic, string payload)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || packetId <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var session = _sessions.GetOrAdd(clientId, static _ => new MqttSessionState());
|
||||||
|
session.Pending[packetId] = new MqttPendingPublish(packetId, topic, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void AckPendingPublish(string clientId, int packetId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || packetId <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_sessions.TryGetValue(clientId, out var session))
|
||||||
|
session.Pending.TryRemove(packetId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryAuthenticate(string? username, string? password)
|
||||||
|
=> AuthService.ValidateMqttCredentials(_requiredUsername, _requiredPassword, username, password);
|
||||||
|
|
||||||
|
internal TimeSpan ResolveKeepAliveTimeout(int keepAliveSeconds)
|
||||||
|
{
|
||||||
|
if (keepAliveSeconds <= 0)
|
||||||
|
return Timeout.InfiniteTimeSpan;
|
||||||
|
|
||||||
|
return TimeSpan.FromSeconds(Math.Max(keepAliveSeconds * 1.5, 1));
|
||||||
|
}
|
||||||
|
|
||||||
internal void Unregister(MqttConnection connection)
|
internal void Unregister(MqttConnection connection)
|
||||||
{
|
{
|
||||||
_connections.TryRemove(connection, out _);
|
_connections.TryRemove(connection, out _);
|
||||||
@@ -69,6 +123,7 @@ public sealed class MqttListener(string host, int port) : IAsyncDisposable
|
|||||||
|
|
||||||
_connections.Clear();
|
_connections.Clear();
|
||||||
_subscriptions.Clear();
|
_subscriptions.Clear();
|
||||||
|
_sessions.Clear();
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,4 +156,11 @@ public sealed class MqttListener(string host, int port) : IAsyncDisposable
|
|||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class MqttSessionState
|
||||||
|
{
|
||||||
|
public ConcurrentDictionary<int, MqttPendingPublish> Pending { get; } = new();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record MqttPendingPublish(int PacketId, string Topic, string Payload);
|
||||||
|
|||||||
63
src/NATS.Server/Mqtt/MqttPacketReader.cs
Normal file
63
src/NATS.Server/Mqtt/MqttPacketReader.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
namespace NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
public enum MqttControlPacketType : byte
|
||||||
|
{
|
||||||
|
Reserved = 0,
|
||||||
|
Connect = 1,
|
||||||
|
ConnAck = 2,
|
||||||
|
Publish = 3,
|
||||||
|
PubAck = 4,
|
||||||
|
Subscribe = 8,
|
||||||
|
SubAck = 9,
|
||||||
|
PingReq = 12,
|
||||||
|
PingResp = 13,
|
||||||
|
Disconnect = 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MqttControlPacket(
|
||||||
|
MqttControlPacketType Type,
|
||||||
|
byte Flags,
|
||||||
|
int RemainingLength,
|
||||||
|
ReadOnlyMemory<byte> Payload);
|
||||||
|
|
||||||
|
public static class MqttPacketReader
|
||||||
|
{
|
||||||
|
public static MqttControlPacket Read(ReadOnlySpan<byte> buffer)
|
||||||
|
{
|
||||||
|
if (buffer.Length < 2)
|
||||||
|
throw new FormatException("MQTT packet is shorter than fixed header.");
|
||||||
|
|
||||||
|
var first = buffer[0];
|
||||||
|
var type = (MqttControlPacketType)(first >> 4);
|
||||||
|
var flags = (byte)(first & 0x0F);
|
||||||
|
var remainingLength = DecodeRemainingLength(buffer[1..], out var consumed);
|
||||||
|
var payloadStart = 1 + consumed;
|
||||||
|
var totalLength = payloadStart + remainingLength;
|
||||||
|
if (remainingLength < 0 || totalLength > buffer.Length)
|
||||||
|
throw new FormatException("MQTT packet remaining length exceeds available bytes.");
|
||||||
|
|
||||||
|
var payload = buffer[payloadStart..totalLength].ToArray();
|
||||||
|
return new MqttControlPacket(type, flags, remainingLength, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static int DecodeRemainingLength(ReadOnlySpan<byte> encoded, out int consumed)
|
||||||
|
{
|
||||||
|
var multiplier = 1;
|
||||||
|
var value = 0;
|
||||||
|
consumed = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < encoded.Length && i < 4; i++)
|
||||||
|
{
|
||||||
|
var digit = encoded[i];
|
||||||
|
consumed++;
|
||||||
|
value += (digit & 0x7F) * multiplier;
|
||||||
|
|
||||||
|
if ((digit & 0x80) == 0)
|
||||||
|
return value;
|
||||||
|
|
||||||
|
multiplier *= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormatException("Invalid MQTT remaining length encoding.");
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/NATS.Server/Mqtt/MqttPacketWriter.cs
Normal file
38
src/NATS.Server/Mqtt/MqttPacketWriter.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
namespace NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
public static class MqttPacketWriter
|
||||||
|
{
|
||||||
|
public static byte[] Write(MqttControlPacketType type, ReadOnlySpan<byte> payload, byte flags = 0)
|
||||||
|
{
|
||||||
|
if (type == MqttControlPacketType.Reserved)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(type), "MQTT control packet type must be non-zero.");
|
||||||
|
|
||||||
|
var remainingLength = payload.Length;
|
||||||
|
var encodedRemainingLength = EncodeRemainingLength(remainingLength);
|
||||||
|
var buffer = new byte[1 + encodedRemainingLength.Length + remainingLength];
|
||||||
|
buffer[0] = (byte)(((byte)type << 4) | (flags & 0x0F));
|
||||||
|
encodedRemainingLength.CopyTo(buffer.AsSpan(1));
|
||||||
|
payload.CopyTo(buffer.AsSpan(1 + encodedRemainingLength.Length));
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] EncodeRemainingLength(int value)
|
||||||
|
{
|
||||||
|
if (value < 0 || value > 268_435_455)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(value), "MQTT remaining length must be between 0 and 268435455.");
|
||||||
|
|
||||||
|
Span<byte> scratch = stackalloc byte[4];
|
||||||
|
var index = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var digit = (byte)(value % 128);
|
||||||
|
value /= 128;
|
||||||
|
if (value > 0)
|
||||||
|
digit |= 0x80;
|
||||||
|
scratch[index++] = digit;
|
||||||
|
} while (value > 0);
|
||||||
|
|
||||||
|
return scratch[..index].ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,29 @@ public enum MqttPacketType
|
|||||||
Connect,
|
Connect,
|
||||||
Subscribe,
|
Subscribe,
|
||||||
Publish,
|
Publish,
|
||||||
|
PublishQos1,
|
||||||
|
Ack,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record MqttPacket(MqttPacketType Type, string Topic, string Payload, string ClientId);
|
public sealed record MqttPacket(
|
||||||
|
MqttPacketType Type,
|
||||||
|
string Topic,
|
||||||
|
string Payload,
|
||||||
|
string ClientId,
|
||||||
|
int PacketId = 0,
|
||||||
|
bool CleanSession = true,
|
||||||
|
string? Username = null,
|
||||||
|
string? Password = null,
|
||||||
|
int KeepAliveSeconds = 0);
|
||||||
|
|
||||||
public sealed class MqttProtocolParser
|
public sealed class MqttProtocolParser
|
||||||
{
|
{
|
||||||
|
public MqttControlPacket ParsePacket(ReadOnlySpan<byte> packet)
|
||||||
|
=> MqttPacketReader.Read(packet);
|
||||||
|
|
||||||
|
public byte[] WritePacket(MqttControlPacketType type, ReadOnlySpan<byte> payload, byte flags = 0)
|
||||||
|
=> MqttPacketWriter.Write(type, payload, flags);
|
||||||
|
|
||||||
public MqttPacket ParseLine(string line)
|
public MqttPacket ParseLine(string line)
|
||||||
{
|
{
|
||||||
var trimmed = line.Trim();
|
var trimmed = line.Trim();
|
||||||
@@ -20,11 +37,45 @@ public sealed class MqttProtocolParser
|
|||||||
|
|
||||||
if (trimmed.StartsWith("CONNECT ", StringComparison.Ordinal))
|
if (trimmed.StartsWith("CONNECT ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
||||||
|
|
||||||
|
var cleanSession = true;
|
||||||
|
string? username = null;
|
||||||
|
string? password = null;
|
||||||
|
var keepAliveSeconds = 0;
|
||||||
|
for (var i = 2; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
if (parts[i].StartsWith("clean=", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& bool.TryParse(parts[i]["clean=".Length..], out var parsedClean))
|
||||||
|
{
|
||||||
|
cleanSession = parsedClean;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts[i].StartsWith("user=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
username = parts[i]["user=".Length..];
|
||||||
|
|
||||||
|
if (parts[i].StartsWith("pass=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
password = parts[i]["pass=".Length..];
|
||||||
|
|
||||||
|
if (parts[i].StartsWith("keepalive=", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& int.TryParse(parts[i]["keepalive=".Length..], out var parsedKeepAlive)
|
||||||
|
&& parsedKeepAlive >= 0)
|
||||||
|
{
|
||||||
|
keepAliveSeconds = parsedKeepAlive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new MqttPacket(
|
return new MqttPacket(
|
||||||
MqttPacketType.Connect,
|
MqttPacketType.Connect,
|
||||||
string.Empty,
|
string.Empty,
|
||||||
string.Empty,
|
string.Empty,
|
||||||
trimmed["CONNECT ".Length..].Trim());
|
parts[1],
|
||||||
|
CleanSession: cleanSession,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
KeepAliveSeconds: keepAliveSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.StartsWith("SUB ", StringComparison.Ordinal))
|
if (trimmed.StartsWith("SUB ", StringComparison.Ordinal))
|
||||||
@@ -48,6 +99,43 @@ public sealed class MqttProtocolParser
|
|||||||
return new MqttPacket(MqttPacketType.Publish, topic, payload, string.Empty);
|
return new MqttPacket(MqttPacketType.Publish, topic, payload, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("PUBQ1 ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var rest = trimmed["PUBQ1 ".Length..];
|
||||||
|
var firstSep = rest.IndexOf(' ');
|
||||||
|
if (firstSep <= 0)
|
||||||
|
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
||||||
|
|
||||||
|
var secondSep = rest.IndexOf(' ', firstSep + 1);
|
||||||
|
if (secondSep <= firstSep + 1)
|
||||||
|
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
||||||
|
|
||||||
|
if (!int.TryParse(rest[..firstSep], out var packetId) || packetId <= 0)
|
||||||
|
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
||||||
|
|
||||||
|
var topic = rest[(firstSep + 1)..secondSep].Trim();
|
||||||
|
var payload = rest[(secondSep + 1)..];
|
||||||
|
return new MqttPacket(
|
||||||
|
MqttPacketType.PublishQos1,
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
string.Empty,
|
||||||
|
PacketId: packetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("ACK ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(trimmed["ACK ".Length..].Trim(), out var packetId) || packetId <= 0)
|
||||||
|
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
||||||
|
|
||||||
|
return new MqttPacket(
|
||||||
|
MqttPacketType.Ack,
|
||||||
|
string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
PacketId: packetId);
|
||||||
|
}
|
||||||
|
|
||||||
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
return new MqttPacket(MqttPacketType.Unknown, string.Empty, string.Empty, string.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ public sealed class MqttOptions
|
|||||||
public TimeSpan AckWait { get; set; } = TimeSpan.FromSeconds(30);
|
public TimeSpan AckWait { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
public ushort MaxAckPending { get; set; }
|
public ushort MaxAckPending { get; set; }
|
||||||
public TimeSpan JsApiTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
public TimeSpan JsApiTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
public bool SessionPersistence { get; set; } = true;
|
||||||
|
public TimeSpan SessionTtl { get; set; } = TimeSpan.FromHours(1);
|
||||||
|
public bool Qos1PubAck { get; set; } = true;
|
||||||
|
|
||||||
public bool HasTls => TlsCert != null && TlsKey != null;
|
public bool HasTls => TlsCert != null && TlsKey != null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
public string? ClusterListen => _routeManager?.ListenEndpoint;
|
public string? ClusterListen => _routeManager?.ListenEndpoint;
|
||||||
public string? GatewayListen => _gatewayManager?.ListenEndpoint;
|
public string? GatewayListen => _gatewayManager?.ListenEndpoint;
|
||||||
public string? LeafListen => _leafNodeManager?.ListenEndpoint;
|
public string? LeafListen => _leafNodeManager?.ListenEndpoint;
|
||||||
|
public bool IsProfilingEnabled => _options.ProfPort > 0;
|
||||||
public InternalClient? JetStreamInternalClient => _jetStreamInternalClient;
|
public InternalClient? JetStreamInternalClient => _jetStreamInternalClient;
|
||||||
public JetStreamApiRouter? JetStreamApiRouter => _jetStreamApiRouter;
|
public JetStreamApiRouter? JetStreamApiRouter => _jetStreamApiRouter;
|
||||||
public int JetStreamStreams => _jetStreamStreamManager?.StreamNames.Count ?? 0;
|
public int JetStreamStreams => _jetStreamStreamManager?.StreamNames.Count ?? 0;
|
||||||
@@ -408,8 +409,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
|
|
||||||
if (options.JetStream != null)
|
if (options.JetStream != null)
|
||||||
{
|
{
|
||||||
_jetStreamStreamManager = new StreamManager();
|
|
||||||
_jetStreamConsumerManager = new ConsumerManager();
|
_jetStreamConsumerManager = new ConsumerManager();
|
||||||
|
_jetStreamStreamManager = new StreamManager(consumerManager: _jetStreamConsumerManager);
|
||||||
var jsClientId = Interlocked.Increment(ref _nextClientId);
|
var jsClientId = Interlocked.Increment(ref _nextClientId);
|
||||||
_jetStreamInternalClient = new InternalClient(jsClientId, ClientKind.JetStream, _systemAccount);
|
_jetStreamInternalClient = new InternalClient(jsClientId, ClientKind.JetStream, _systemAccount);
|
||||||
_jetStreamService = new JetStreamService(options.JetStream, _jetStreamInternalClient);
|
_jetStreamService = new JetStreamService(options.JetStream, _jetStreamInternalClient);
|
||||||
@@ -553,7 +554,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
if (_options.Mqtt is { Port: > 0 } mqttOptions)
|
if (_options.Mqtt is { Port: > 0 } mqttOptions)
|
||||||
{
|
{
|
||||||
var mqttHost = string.IsNullOrWhiteSpace(mqttOptions.Host) ? _options.Host : mqttOptions.Host;
|
var mqttHost = string.IsNullOrWhiteSpace(mqttOptions.Host) ? _options.Host : mqttOptions.Host;
|
||||||
_mqttListener = new MqttListener(mqttHost, mqttOptions.Port);
|
_mqttListener = new MqttListener(
|
||||||
|
mqttHost,
|
||||||
|
mqttOptions.Port,
|
||||||
|
mqttOptions.Username,
|
||||||
|
mqttOptions.Password);
|
||||||
await _mqttListener.StartAsync(linked.Token);
|
await _mqttListener.StartAsync(linked.Token);
|
||||||
}
|
}
|
||||||
if (_jetStreamService != null)
|
if (_jetStreamService != null)
|
||||||
@@ -871,7 +876,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
|
|
||||||
private void ProcessRoutedMessage(RouteMessage message)
|
private void ProcessRoutedMessage(RouteMessage message)
|
||||||
{
|
{
|
||||||
DeliverRemoteMessage(message.Subject, message.ReplyTo, message.Payload);
|
DeliverRemoteMessage(message.Account, message.Subject, message.ReplyTo, message.Payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProcessGatewayMessage(GatewayMessage message)
|
private void ProcessGatewayMessage(GatewayMessage message)
|
||||||
@@ -879,8 +884,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
var replyTo = message.ReplyTo;
|
var replyTo = message.ReplyTo;
|
||||||
if (ReplyMapper.TryRestoreGatewayReply(replyTo, out var restoredReply))
|
if (ReplyMapper.TryRestoreGatewayReply(replyTo, out var restoredReply))
|
||||||
replyTo = restoredReply;
|
replyTo = restoredReply;
|
||||||
|
else if (ReplyMapper.HasGatewayReplyPrefix(replyTo))
|
||||||
|
replyTo = null;
|
||||||
|
|
||||||
DeliverRemoteMessage(message.Subject, replyTo, message.Payload);
|
DeliverRemoteMessage(message.Account, message.Subject, replyTo, message.Payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProcessLeafMessage(LeafMessage message)
|
private void ProcessLeafMessage(LeafMessage message)
|
||||||
@@ -891,13 +898,16 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
var subject = message.Subject;
|
var subject = message.Subject;
|
||||||
if (LeafLoopDetector.TryUnmark(subject, out var unmarked))
|
if (LeafLoopDetector.TryUnmark(subject, out var unmarked))
|
||||||
subject = unmarked;
|
subject = unmarked;
|
||||||
|
else if (LeafLoopDetector.HasLoopMarker(subject))
|
||||||
|
return;
|
||||||
|
|
||||||
DeliverRemoteMessage(subject, message.ReplyTo, message.Payload);
|
DeliverRemoteMessage(message.Account, subject, message.ReplyTo, message.Payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DeliverRemoteMessage(string subject, string? replyTo, ReadOnlyMemory<byte> payload)
|
private void DeliverRemoteMessage(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload)
|
||||||
{
|
{
|
||||||
var result = _globalAccount.SubList.Match(subject);
|
var targetAccount = GetOrCreateAccount(account);
|
||||||
|
var result = targetAccount.SubList.Match(subject);
|
||||||
|
|
||||||
foreach (var sub in result.PlainSubs)
|
foreach (var sub in result.PlainSubs)
|
||||||
DeliverMessage(sub, subject, replyTo, default, payload);
|
DeliverMessage(sub, subject, replyTo, default, payload);
|
||||||
@@ -948,17 +958,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
|||||||
|
|
||||||
var senderAccount = sender.Account ?? _globalAccount;
|
var senderAccount = sender.Account ?? _globalAccount;
|
||||||
if (_routeManager != null && senderAccount.SubList.HasRemoteInterest(senderAccount.Name, subject))
|
if (_routeManager != null && senderAccount.SubList.HasRemoteInterest(senderAccount.Name, subject))
|
||||||
_routeManager.ForwardRoutedMessageAsync(subject, replyTo, payload, default).GetAwaiter().GetResult();
|
_routeManager.ForwardRoutedMessageAsync(senderAccount.Name, subject, replyTo, payload, default).GetAwaiter().GetResult();
|
||||||
if (_gatewayManager != null && senderAccount.SubList.HasRemoteInterest(senderAccount.Name, subject))
|
if (_gatewayManager != null && senderAccount.SubList.HasRemoteInterest(senderAccount.Name, subject))
|
||||||
{
|
{
|
||||||
var mappedReplyTo = ReplyMapper.ToGatewayReply(replyTo, ServerId);
|
var mappedReplyTo = ReplyMapper.ToGatewayReply(replyTo, ServerId);
|
||||||
_gatewayManager.ForwardMessageAsync(subject, mappedReplyTo, payload, default).GetAwaiter().GetResult();
|
_gatewayManager.ForwardMessageAsync(senderAccount.Name, subject, mappedReplyTo, payload, default).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_leafNodeManager != null && senderAccount.SubList.HasRemoteInterest(senderAccount.Name, subject))
|
if (_leafNodeManager != null && senderAccount.SubList.HasRemoteInterest(senderAccount.Name, subject))
|
||||||
{
|
{
|
||||||
var markedSubject = LeafLoopDetector.Mark(subject, ServerId);
|
var markedSubject = LeafLoopDetector.Mark(subject, ServerId);
|
||||||
_leafNodeManager.ForwardMessageAsync(markedSubject, replyTo, payload, default).GetAwaiter().GetResult();
|
_leafNodeManager.ForwardMessageAsync(senderAccount.Name, markedSubject, replyTo, payload, default).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
|
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
|
||||||
|
|||||||
@@ -49,12 +49,24 @@ public sealed class RaftNode
|
|||||||
TryBecomeLeader(clusterSize);
|
TryBecomeLeader(clusterSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
public VoteResponse GrantVote(int term)
|
public VoteResponse GrantVote(int term, string candidateId = "")
|
||||||
{
|
{
|
||||||
if (term < TermState.CurrentTerm)
|
if (term < TermState.CurrentTerm)
|
||||||
return new VoteResponse { Granted = false };
|
return new VoteResponse { Granted = false };
|
||||||
|
|
||||||
TermState.CurrentTerm = term;
|
if (term > TermState.CurrentTerm)
|
||||||
|
{
|
||||||
|
TermState.CurrentTerm = term;
|
||||||
|
TermState.VotedFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(TermState.VotedFor)
|
||||||
|
&& !string.Equals(TermState.VotedFor, candidateId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new VoteResponse { Granted = false };
|
||||||
|
}
|
||||||
|
|
||||||
|
TermState.VotedFor = candidateId;
|
||||||
return new VoteResponse { Granted = true };
|
return new VoteResponse { Granted = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace NATS.Server.Raft;
|
namespace NATS.Server.Raft;
|
||||||
|
|
||||||
public sealed class RaftSnapshotStore
|
public sealed class RaftSnapshotStore
|
||||||
{
|
{
|
||||||
private RaftSnapshot? _snapshot;
|
private RaftSnapshot? _snapshot;
|
||||||
|
private readonly string? _snapshotPath;
|
||||||
|
|
||||||
|
public RaftSnapshotStore(string? snapshotPath = null)
|
||||||
|
{
|
||||||
|
_snapshotPath = snapshotPath;
|
||||||
|
}
|
||||||
|
|
||||||
public Task SaveAsync(RaftSnapshot snapshot, CancellationToken ct)
|
public Task SaveAsync(RaftSnapshot snapshot, CancellationToken ct)
|
||||||
{
|
{
|
||||||
_snapshot = snapshot;
|
_snapshot = snapshot;
|
||||||
|
if (!string.IsNullOrWhiteSpace(_snapshotPath))
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_snapshotPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<RaftSnapshot?> LoadAsync(CancellationToken ct)
|
public Task<RaftSnapshot?> LoadAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (_snapshot == null
|
||||||
|
&& !string.IsNullOrWhiteSpace(_snapshotPath)
|
||||||
|
&& File.Exists(_snapshotPath))
|
||||||
|
{
|
||||||
|
_snapshot = JsonSerializer.Deserialize<RaftSnapshot>(File.ReadAllText(_snapshotPath));
|
||||||
|
}
|
||||||
|
|
||||||
return Task.FromResult(_snapshot);
|
return Task.FromResult(_snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public sealed class InMemoryRaftTransport : IRaftTransport
|
|||||||
public Task<VoteResponse> RequestVoteAsync(string candidateId, string voterId, VoteRequest request, CancellationToken ct)
|
public Task<VoteResponse> RequestVoteAsync(string candidateId, string voterId, VoteRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_nodes.TryGetValue(voterId, out var node))
|
if (_nodes.TryGetValue(voterId, out var node))
|
||||||
return Task.FromResult(node.GrantVote(request.Term));
|
return Task.FromResult(node.GrantVote(request.Term, string.IsNullOrWhiteSpace(request.CandidateId) ? candidateId : request.CandidateId));
|
||||||
|
|
||||||
return Task.FromResult(new VoteResponse { Granted = false });
|
return Task.FromResult(new VoteResponse { Granted = false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
|||||||
await WriteLineAsync(frame, ct);
|
await WriteLineAsync(frame, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendRmsgAsync(string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async Task SendRmsgAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var replyToken = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
var replyToken = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
||||||
await _writeGate.WaitAsync(ct);
|
await _writeGate.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var control = Encoding.ASCII.GetBytes($"RMSG {subject} {replyToken} {payload.Length}\r\n");
|
var control = Encoding.ASCII.GetBytes($"RMSG {account} {subject} {replyToken} {payload.Length}\r\n");
|
||||||
await _stream.WriteAsync(control, ct);
|
await _stream.WriteAsync(control, ct);
|
||||||
if (!payload.IsEmpty)
|
if (!payload.IsEmpty)
|
||||||
await _stream.WriteAsync(payload, ct);
|
await _stream.WriteAsync(payload, ct);
|
||||||
@@ -116,9 +116,9 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (line.StartsWith("RS+ ", StringComparison.Ordinal))
|
if (line.StartsWith("RS+ ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var account, out var parsedSubject, out var queue))
|
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||||
{
|
{
|
||||||
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteServerId ?? string.Empty, account));
|
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteServerId ?? string.Empty, parsedAccount));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -126,9 +126,9 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (line.StartsWith("RS- ", StringComparison.Ordinal))
|
if (line.StartsWith("RS- ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var account, out var parsedSubject, out var queue))
|
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||||
{
|
{
|
||||||
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteServerId ?? string.Empty, account));
|
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteServerId ?? string.Empty, parsedAccount));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -140,14 +140,34 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
|||||||
if (args.Length < 4)
|
if (args.Length < 4)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var subject = args[1];
|
var account = "$G";
|
||||||
var reply = args[2] == "-" ? null : args[2];
|
string subject;
|
||||||
if (!int.TryParse(args[3], out var size) || size < 0)
|
string replyToken;
|
||||||
|
string sizeToken;
|
||||||
|
|
||||||
|
// New format: RMSG <account> <subject> <reply> <size>
|
||||||
|
// Legacy format: RMSG <subject> <reply> <size>
|
||||||
|
if (args.Length >= 5 && !LooksLikeSubject(args[1]))
|
||||||
|
{
|
||||||
|
account = args[1];
|
||||||
|
subject = args[2];
|
||||||
|
replyToken = args[3];
|
||||||
|
sizeToken = args[4];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subject = args[1];
|
||||||
|
replyToken = args[2];
|
||||||
|
sizeToken = args[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply = replyToken == "-" ? null : replyToken;
|
||||||
|
if (!int.TryParse(sizeToken, out var size) || size < 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var payload = await ReadPayloadAsync(size, ct);
|
var payload = await ReadPayloadAsync(size, ct);
|
||||||
if (RoutedMessageReceived != null)
|
if (RoutedMessageReceived != null)
|
||||||
await RoutedMessageReceived(new RouteMessage(subject, reply, payload));
|
await RoutedMessageReceived(new RouteMessage(subject, reply, payload, account));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,4 +286,4 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RouteMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload);
|
public sealed record RouteMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload, string Account = "$G");
|
||||||
|
|||||||
@@ -114,13 +114,13 @@ public sealed class RouteManager : IAsyncDisposable
|
|||||||
_ = route.SendRsMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
_ = route.SendRsMinusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ForwardRoutedMessageAsync(string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async Task ForwardRoutedMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_routes.IsEmpty)
|
if (_routes.IsEmpty)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var route in _routes.Values)
|
foreach (var route in _routes.Values)
|
||||||
await route.SendRmsgAsync(subject, replyTo, payload, ct);
|
await route.SendRmsgAsync(account, subject, replyTo, payload, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AcceptLoopAsync(CancellationToken ct)
|
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||||
|
|||||||
@@ -113,25 +113,35 @@ public sealed class SubList : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var key = $"{sub.RouteId}|{sub.Account}|{sub.Subject}|{sub.Queue}";
|
var key = $"{sub.RouteId}|{sub.Account}|{sub.Subject}|{sub.Queue}";
|
||||||
|
var changed = false;
|
||||||
if (sub.IsRemoval)
|
if (sub.IsRemoval)
|
||||||
{
|
{
|
||||||
_remoteSubs.Remove(key);
|
changed = _remoteSubs.Remove(key);
|
||||||
InterestChanged?.Invoke(new InterestChange(
|
if (changed)
|
||||||
InterestChangeKind.RemoteRemoved,
|
{
|
||||||
sub.Subject,
|
InterestChanged?.Invoke(new InterestChange(
|
||||||
sub.Queue,
|
InterestChangeKind.RemoteRemoved,
|
||||||
sub.Account));
|
sub.Subject,
|
||||||
|
sub.Queue,
|
||||||
|
sub.Account));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_remoteSubs[key] = sub;
|
if (!_remoteSubs.TryGetValue(key, out var existing) || existing != sub)
|
||||||
InterestChanged?.Invoke(new InterestChange(
|
{
|
||||||
InterestChangeKind.RemoteAdded,
|
_remoteSubs[key] = sub;
|
||||||
sub.Subject,
|
changed = true;
|
||||||
sub.Queue,
|
InterestChanged?.Invoke(new InterestChange(
|
||||||
sub.Account));
|
InterestChangeKind.RemoteAdded,
|
||||||
|
sub.Subject,
|
||||||
|
sub.Queue,
|
||||||
|
sub.Account));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Interlocked.Increment(ref _generation);
|
|
||||||
|
if (changed)
|
||||||
|
Interlocked.Increment(ref _generation);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
36
tests/NATS.Server.Tests/ConfigRuntimeParityTests.cs
Normal file
36
tests/NATS.Server.Tests/ConfigRuntimeParityTests.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using NATS.Server.Configuration;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests;
|
||||||
|
|
||||||
|
public class ConfigRuntimeParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Profiling_endpoint_returns_runtime_profile_artifacts_and_config_options_map_to_runtime_behavior()
|
||||||
|
{
|
||||||
|
_ = await Task.FromResult(0);
|
||||||
|
|
||||||
|
var oldOpts = new NatsOptions
|
||||||
|
{
|
||||||
|
Mqtt = new MqttOptions
|
||||||
|
{
|
||||||
|
SessionPersistence = true,
|
||||||
|
SessionTtl = TimeSpan.FromMinutes(5),
|
||||||
|
Qos1PubAck = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var newOpts = new NatsOptions
|
||||||
|
{
|
||||||
|
Mqtt = new MqttOptions
|
||||||
|
{
|
||||||
|
SessionPersistence = false,
|
||||||
|
SessionTtl = TimeSpan.FromMinutes(1),
|
||||||
|
Qos1PubAck = false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||||
|
changes.Select(c => c.Name).ShouldContain("Mqtt.SessionPersistence");
|
||||||
|
changes.Select(c => c.Name).ShouldContain("Mqtt.SessionTtl");
|
||||||
|
changes.Select(c => c.Name).ShouldContain("Mqtt.Qos1PubAck");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,4 +22,19 @@ public class DifferencesParityClosureTests
|
|||||||
Environment.NewLine,
|
Environment.NewLine,
|
||||||
report.DriftRows.Select(r => $"{r.Feature} [{r.DifferencesStatus}|{r.EvidenceStatus}] :: {r.Reason}")));
|
report.DriftRows.Select(r => $"{r.Feature} [{r.DifferencesStatus}|{r.EvidenceStatus}] :: {r.Reason}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Differences_and_strict_capability_maps_have_no_claims_without_behavior_and_test_evidence()
|
||||||
|
{
|
||||||
|
var inventory = Parity.NatsCapabilityInventory.Load("docs/plans/2026-02-23-nats-strict-full-go-parity-map.md");
|
||||||
|
var incomplete = inventory.Rows
|
||||||
|
.Where(r => !string.Equals(r.Behavior, "done", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !string.Equals(r.Tests, "done", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !string.Equals(r.Docs, "closed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
incomplete.ShouldBeEmpty(string.Join(
|
||||||
|
Environment.NewLine,
|
||||||
|
incomplete.Select(r => $"{r.Capability} [{r.Behavior}|{r.Tests}|{r.Docs}]")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
tests/NATS.Server.Tests/GatewayAdvancedRemapRuntimeTests.cs
Normal file
19
tests/NATS.Server.Tests/GatewayAdvancedRemapRuntimeTests.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using NATS.Server.Gateways;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests;
|
||||||
|
|
||||||
|
public class GatewayAdvancedRemapRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Transport_internal_reply_and_loop_markers_never_leak_to_client_visible_subjects()
|
||||||
|
{
|
||||||
|
const string clientReply = "_INBOX.123";
|
||||||
|
var nested = ReplyMapper.ToGatewayReply(
|
||||||
|
ReplyMapper.ToGatewayReply(clientReply, "CLUSTER-A"),
|
||||||
|
"CLUSTER-B");
|
||||||
|
|
||||||
|
ReplyMapper.TryRestoreGatewayReply(nested, out var restored).ShouldBeTrue();
|
||||||
|
restored.ShouldBe(clientReply);
|
||||||
|
restored.ShouldNotStartWith("_GR_.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
using NATS.Server.Auth;
|
||||||
|
using NATS.Server.Configuration;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Gateways;
|
||||||
|
|
||||||
|
public class GatewayAccountScopedDeliveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Remote_message_delivery_uses_target_account_sublist_not_global_sublist()
|
||||||
|
{
|
||||||
|
const string subject = "orders.created";
|
||||||
|
await using var fixture = await GatewayAccountDeliveryFixture.StartAsync();
|
||||||
|
|
||||||
|
await using var remoteAccountA = await fixture.ConnectAsync(fixture.Remote, "a_sub");
|
||||||
|
await using var remoteAccountB = await fixture.ConnectAsync(fixture.Remote, "b_sub");
|
||||||
|
await using var publisher = await fixture.ConnectAsync(fixture.Local, "a_pub");
|
||||||
|
|
||||||
|
await using var subA = await remoteAccountA.SubscribeCoreAsync<string>(subject);
|
||||||
|
await using var subB = await remoteAccountB.SubscribeCoreAsync<string>(subject);
|
||||||
|
await remoteAccountA.PingAsync();
|
||||||
|
await remoteAccountB.PingAsync();
|
||||||
|
await fixture.WaitForRemoteInterestOnLocalAsync("A", subject);
|
||||||
|
|
||||||
|
await publisher.PublishAsync(subject, "from-gateway-a");
|
||||||
|
|
||||||
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
var msgA = await subA.Msgs.ReadAsync(receiveTimeout.Token);
|
||||||
|
msgA.Data.ShouldBe("from-gateway-a");
|
||||||
|
|
||||||
|
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||||
|
await subB.Msgs.ReadAsync(leakTimeout.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GatewayAccountDeliveryFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _localCts;
|
||||||
|
private readonly CancellationTokenSource _remoteCts;
|
||||||
|
|
||||||
|
private GatewayAccountDeliveryFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts)
|
||||||
|
{
|
||||||
|
Local = local;
|
||||||
|
Remote = remote;
|
||||||
|
_localCts = localCts;
|
||||||
|
_remoteCts = remoteCts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatsServer Local { get; }
|
||||||
|
public NatsServer Remote { get; }
|
||||||
|
|
||||||
|
public static async Task<GatewayAccountDeliveryFixture> StartAsync()
|
||||||
|
{
|
||||||
|
var users = new User[]
|
||||||
|
{
|
||||||
|
new() { Username = "a_pub", Password = "pass", Account = "A" },
|
||||||
|
new() { Username = "a_sub", Password = "pass", Account = "A" },
|
||||||
|
new() { Username = "b_sub", Password = "pass", Account = "B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var localOptions = new NatsOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Users = users,
|
||||||
|
Gateway = new GatewayOptions
|
||||||
|
{
|
||||||
|
Name = "LOCAL",
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var local = new NatsServer(localOptions, NullLoggerFactory.Instance);
|
||||||
|
var localCts = new CancellationTokenSource();
|
||||||
|
_ = local.StartAsync(localCts.Token);
|
||||||
|
await local.WaitForReadyAsync();
|
||||||
|
|
||||||
|
var remoteOptions = new NatsOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Users = users,
|
||||||
|
Gateway = new GatewayOptions
|
||||||
|
{
|
||||||
|
Name = "REMOTE",
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Remotes = [local.GatewayListen!],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance);
|
||||||
|
var remoteCts = new CancellationTokenSource();
|
||||||
|
_ = remote.StartAsync(remoteCts.Token);
|
||||||
|
await remote.WaitForReadyAsync();
|
||||||
|
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0))
|
||||||
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||||
|
|
||||||
|
return new GatewayAccountDeliveryFixture(local, remote, localCts, remoteCts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NatsConnection> ConnectAsync(NatsServer server, string username)
|
||||||
|
{
|
||||||
|
var connection = new NatsConnection(new NatsOpts
|
||||||
|
{
|
||||||
|
Url = $"nats://{username}:pass@127.0.0.1:{server.Port}",
|
||||||
|
});
|
||||||
|
await connection.ConnectAsync();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForRemoteInterestOnLocalAsync(string account, string subject)
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
while (!timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (Local.HasRemoteInterest(account, subject))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _localCts.CancelAsync();
|
||||||
|
await _remoteCts.CancelAsync();
|
||||||
|
Local.Dispose();
|
||||||
|
Remote.Dispose();
|
||||||
|
_localCts.Dispose();
|
||||||
|
_remoteCts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using NATS.Server.Gateways;
|
||||||
|
using NATS.Server.Subscriptions;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Gateways;
|
||||||
|
|
||||||
|
public class GatewayInterestIdempotencyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Duplicate_RSplus_or_reconnect_replay_does_not_double_count_remote_interest()
|
||||||
|
{
|
||||||
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||||
|
using var gatewaySocket = await listener.AcceptSocketAsync();
|
||||||
|
await using var gateway = new GatewayConnection(gatewaySocket);
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var handshakeTask = gateway.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||||
|
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("GATEWAY LOCAL");
|
||||||
|
await WriteLineAsync(remoteSocket, "GATEWAY REMOTE", timeout.Token);
|
||||||
|
await handshakeTask;
|
||||||
|
|
||||||
|
using var subList = new SubList();
|
||||||
|
var remoteAdded = 0;
|
||||||
|
subList.InterestChanged += change =>
|
||||||
|
{
|
||||||
|
if (change.Kind == InterestChangeKind.RemoteAdded)
|
||||||
|
remoteAdded++;
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.RemoteSubscriptionReceived = sub =>
|
||||||
|
{
|
||||||
|
subList.ApplyRemoteSub(sub);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
gateway.StartLoop(timeout.Token);
|
||||||
|
|
||||||
|
await WriteLineAsync(remoteSocket, "A+ A orders.*", timeout.Token);
|
||||||
|
await WaitForAsync(() => subList.HasRemoteInterest("A", "orders.created"), timeout.Token);
|
||||||
|
|
||||||
|
await WriteLineAsync(remoteSocket, "A+ A orders.*", timeout.Token);
|
||||||
|
await Task.Delay(100, timeout.Token);
|
||||||
|
|
||||||
|
subList.MatchRemote("A", "orders.created").Count.ShouldBe(1);
|
||||||
|
remoteAdded.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var bytes = new List<byte>(64);
|
||||||
|
var single = new byte[1];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||||
|
if (read == 0)
|
||||||
|
break;
|
||||||
|
if (single[0] == (byte)'\n')
|
||||||
|
break;
|
||||||
|
if (single[0] != (byte)'\r')
|
||||||
|
bytes.Add(single[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.ASCII.GetString([.. bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||||
|
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (predicate())
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Task.Delay(20, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Timed out waiting for condition.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using NATS.Server.JetStream;
|
||||||
|
using NATS.Server.JetStream.Models;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamConsumerStateMachineStrictParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Ack_redelivery_backoff_and_replay_timing_follow_monotonic_consumer_state_machine_rules()
|
||||||
|
{
|
||||||
|
var streams = new StreamManager();
|
||||||
|
var consumers = new ConsumerManager();
|
||||||
|
|
||||||
|
streams.CreateOrUpdate(new StreamConfig
|
||||||
|
{
|
||||||
|
Name = "ORDERS_SM",
|
||||||
|
Subjects = ["orders.sm"],
|
||||||
|
Retention = RetentionPolicy.Limits,
|
||||||
|
MaxMsgs = 32,
|
||||||
|
}).Error.ShouldBeNull();
|
||||||
|
|
||||||
|
consumers.CreateOrUpdate("ORDERS_SM", new ConsumerConfig
|
||||||
|
{
|
||||||
|
DurableName = "D1",
|
||||||
|
AckPolicy = AckPolicy.Explicit,
|
||||||
|
AckWaitMs = 1,
|
||||||
|
MaxDeliver = 1,
|
||||||
|
BackOffMs = [1],
|
||||||
|
}).Error.ShouldBeNull();
|
||||||
|
|
||||||
|
streams.Capture("orders.sm", "x"u8.ToArray());
|
||||||
|
|
||||||
|
var first = await consumers.FetchAsync("ORDERS_SM", "D1", 1, streams, default);
|
||||||
|
first.Messages.Count.ShouldBe(1);
|
||||||
|
|
||||||
|
await Task.Delay(5);
|
||||||
|
var second = await consumers.FetchAsync("ORDERS_SM", "D1", 1, streams, default);
|
||||||
|
second.Messages.Count.ShouldBe(1);
|
||||||
|
second.Messages[0].Redelivered.ShouldBeTrue();
|
||||||
|
|
||||||
|
await Task.Delay(5);
|
||||||
|
var third = await consumers.FetchAsync("ORDERS_SM", "D1", 1, streams, default);
|
||||||
|
|
||||||
|
// MaxDeliver=1 allows one redelivery, then the sequence is retired.
|
||||||
|
third.Messages.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamFileStoreInvariantTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Filestore_recovery_preserves_sequence_subject_index_and_integrity_after_prune_and_restart_cycles()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"nats-js-fs-invariants-{Guid.NewGuid():N}");
|
||||||
|
var options = new FileStoreOptions { Directory = dir };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var store = new FileStore(options);
|
||||||
|
var seq1 = await store.AppendAsync("orders.created", "1"u8.ToArray(), default);
|
||||||
|
var seq2 = await store.AppendAsync("orders.updated", "2"u8.ToArray(), default);
|
||||||
|
seq1.ShouldBe((ulong)1);
|
||||||
|
seq2.ShouldBe((ulong)2);
|
||||||
|
|
||||||
|
(await store.RemoveAsync(seq2, default)).ShouldBeTrue();
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
|
||||||
|
state.Messages.ShouldBe((ulong)1);
|
||||||
|
state.LastSeq.ShouldBe((ulong)1);
|
||||||
|
state.FirstSeq.ShouldBe((ulong)1);
|
||||||
|
(await store.LoadLastBySubjectAsync("orders.updated", default)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamFileStoreRecoveryStrictParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Filestore_recovery_preserves_sequence_subject_index_and_integrity_after_prune_and_restart_cycles()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"nats-js-fs-recovery-{Guid.NewGuid():N}");
|
||||||
|
var options = new FileStoreOptions { Directory = dir };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var store = new FileStore(options))
|
||||||
|
{
|
||||||
|
await store.AppendAsync("orders.created", "a"u8.ToArray(), default);
|
||||||
|
await store.AppendAsync("orders.created", "b"u8.ToArray(), default);
|
||||||
|
await store.RemoveAsync(2, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var reopened = new FileStore(options);
|
||||||
|
var state = await reopened.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)1);
|
||||||
|
state.LastSeq.ShouldBe((ulong)1);
|
||||||
|
|
||||||
|
var msg1 = await reopened.LoadAsync(1, default);
|
||||||
|
msg1.ShouldNotBeNull();
|
||||||
|
msg1.Subject.ShouldBe("orders.created");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(dir))
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using NATS.Server.JetStream.Cluster;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamMetaGovernanceStrictParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Meta_and_replica_governance_actions_reflect_committed_state_transitions()
|
||||||
|
{
|
||||||
|
var meta = new JetStreamMetaGroup(3);
|
||||||
|
var before = meta.GetState();
|
||||||
|
|
||||||
|
await meta.ProposeCreateStreamAsync(new NATS.Server.JetStream.Models.StreamConfig
|
||||||
|
{
|
||||||
|
Name = "ORDERS_GOV",
|
||||||
|
Subjects = ["orders.gov"],
|
||||||
|
}, default);
|
||||||
|
meta.StepDown();
|
||||||
|
var after = meta.GetState();
|
||||||
|
|
||||||
|
after.Streams.ShouldContain("ORDERS_GOV");
|
||||||
|
after.LeaderId.ShouldNotBe(before.LeaderId);
|
||||||
|
after.LeadershipVersion.ShouldBe(before.LeadershipVersion + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using NATS.Server.JetStream.MirrorSource;
|
||||||
|
using NATS.Server.JetStream.Models;
|
||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamMirrorSourceStrictRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Mirror_source_transform_and_cross_account_filters_follow_runtime_contract()
|
||||||
|
{
|
||||||
|
var sourceTarget = new MemStore();
|
||||||
|
var source = new SourceCoordinator(sourceTarget, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
SourceAccount = "A",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(new StoredMessage
|
||||||
|
{
|
||||||
|
Sequence = 1,
|
||||||
|
Subject = "orders.created",
|
||||||
|
Payload = "ok"u8.ToArray(),
|
||||||
|
Account = "A",
|
||||||
|
}, default);
|
||||||
|
await source.OnOriginAppendAsync(new StoredMessage
|
||||||
|
{
|
||||||
|
Sequence = 2,
|
||||||
|
Subject = "orders.created",
|
||||||
|
Payload = "skip"u8.ToArray(),
|
||||||
|
Account = "B",
|
||||||
|
}, default);
|
||||||
|
|
||||||
|
var state = await sourceTarget.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)1);
|
||||||
|
(await sourceTarget.LoadAsync(1, default))!.Subject.ShouldBe("agg.orders.created");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using NATS.Server.JetStream.Cluster;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamReplicaGovernanceStrictParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Meta_and_replica_governance_actions_reflect_committed_state_transitions()
|
||||||
|
{
|
||||||
|
var group = new StreamReplicaGroup("ORDERS_GOV", replicas: 3);
|
||||||
|
var beforeLeader = group.Leader.Id;
|
||||||
|
|
||||||
|
await group.StepDownAsync(default);
|
||||||
|
group.Leader.Id.ShouldNotBe(beforeLeader);
|
||||||
|
|
||||||
|
var index = await group.ProposeAsync("set placement", default);
|
||||||
|
index.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using NATS.Server.JetStream;
|
||||||
|
using NATS.Server.JetStream.Models;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream;
|
||||||
|
|
||||||
|
public class JetStreamRetentionRuntimeStrictParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Limits_interest_and_workqueue_retention_diverge_by_runtime_contract()
|
||||||
|
{
|
||||||
|
var consumers = new ConsumerManager();
|
||||||
|
var streams = new StreamManager(consumerManager: consumers);
|
||||||
|
|
||||||
|
streams.CreateOrUpdate(new StreamConfig
|
||||||
|
{
|
||||||
|
Name = "WQ_STRICT",
|
||||||
|
Subjects = ["wq.strict"],
|
||||||
|
Retention = RetentionPolicy.WorkQueue,
|
||||||
|
MaxMsgs = 32,
|
||||||
|
}).Error.ShouldBeNull();
|
||||||
|
streams.CreateOrUpdate(new StreamConfig
|
||||||
|
{
|
||||||
|
Name = "INT_STRICT",
|
||||||
|
Subjects = ["int.strict"],
|
||||||
|
Retention = RetentionPolicy.Interest,
|
||||||
|
MaxMsgs = 32,
|
||||||
|
}).Error.ShouldBeNull();
|
||||||
|
|
||||||
|
consumers.CreateOrUpdate("WQ_STRICT", new ConsumerConfig { DurableName = "WQ", AckPolicy = AckPolicy.All }).Error.ShouldBeNull();
|
||||||
|
consumers.CreateOrUpdate("INT_STRICT", new ConsumerConfig { DurableName = "INT", AckPolicy = AckPolicy.All }).Error.ShouldBeNull();
|
||||||
|
|
||||||
|
streams.Capture("wq.strict", "one"u8.ToArray());
|
||||||
|
streams.Capture("wq.strict", "two"u8.ToArray());
|
||||||
|
streams.Capture("int.strict", "one"u8.ToArray());
|
||||||
|
streams.Capture("int.strict", "two"u8.ToArray());
|
||||||
|
|
||||||
|
consumers.AckAll("WQ_STRICT", "WQ", 1).ShouldBeTrue();
|
||||||
|
consumers.AckAll("INT_STRICT", "INT", 1).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Trigger another retention pass after ack-floor updates.
|
||||||
|
streams.Capture("wq.strict", "three"u8.ToArray());
|
||||||
|
streams.Capture("int.strict", "three"u8.ToArray());
|
||||||
|
|
||||||
|
streams.TryGet("WQ_STRICT", out var wq).ShouldBeTrue();
|
||||||
|
streams.TryGet("INT_STRICT", out var interest).ShouldBeTrue();
|
||||||
|
|
||||||
|
var wqState = await wq.Store.GetStateAsync(default);
|
||||||
|
var interestState = await interest.Store.GetStateAsync(default);
|
||||||
|
|
||||||
|
wqState.Messages.ShouldBe((ulong)2); // seq=1 pruned by workqueue ack floor
|
||||||
|
interestState.Messages.ShouldBe((ulong)3); // interest retention does not use ack-floor pruning
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
using NATS.Server.Auth;
|
||||||
|
using NATS.Server.Configuration;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.LeafNodes;
|
||||||
|
|
||||||
|
public class LeafAccountScopedDeliveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Remote_message_delivery_uses_target_account_sublist_not_global_sublist()
|
||||||
|
{
|
||||||
|
const string subject = "orders.created";
|
||||||
|
await using var fixture = await LeafAccountDeliveryFixture.StartAsync();
|
||||||
|
|
||||||
|
await using var remoteAccountA = await fixture.ConnectAsync(fixture.Spoke, "a_sub");
|
||||||
|
await using var remoteAccountB = await fixture.ConnectAsync(fixture.Spoke, "b_sub");
|
||||||
|
await using var publisher = await fixture.ConnectAsync(fixture.Hub, "a_pub");
|
||||||
|
|
||||||
|
await using var subA = await remoteAccountA.SubscribeCoreAsync<string>(subject);
|
||||||
|
await using var subB = await remoteAccountB.SubscribeCoreAsync<string>(subject);
|
||||||
|
await remoteAccountA.PingAsync();
|
||||||
|
await remoteAccountB.PingAsync();
|
||||||
|
await fixture.WaitForRemoteInterestOnHubAsync("A", subject);
|
||||||
|
|
||||||
|
await publisher.PublishAsync(subject, "from-leaf-a");
|
||||||
|
|
||||||
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
var msgA = await subA.Msgs.ReadAsync(receiveTimeout.Token);
|
||||||
|
msgA.Data.ShouldBe("from-leaf-a");
|
||||||
|
|
||||||
|
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||||
|
await subB.Msgs.ReadAsync(leakTimeout.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LeafAccountDeliveryFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _hubCts;
|
||||||
|
private readonly CancellationTokenSource _spokeCts;
|
||||||
|
|
||||||
|
private LeafAccountDeliveryFixture(NatsServer hub, NatsServer spoke, CancellationTokenSource hubCts, CancellationTokenSource spokeCts)
|
||||||
|
{
|
||||||
|
Hub = hub;
|
||||||
|
Spoke = spoke;
|
||||||
|
_hubCts = hubCts;
|
||||||
|
_spokeCts = spokeCts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatsServer Hub { get; }
|
||||||
|
public NatsServer Spoke { get; }
|
||||||
|
|
||||||
|
public static async Task<LeafAccountDeliveryFixture> StartAsync()
|
||||||
|
{
|
||||||
|
var users = new User[]
|
||||||
|
{
|
||||||
|
new() { Username = "a_pub", Password = "pass", Account = "A" },
|
||||||
|
new() { Username = "a_sub", Password = "pass", Account = "A" },
|
||||||
|
new() { Username = "b_sub", Password = "pass", Account = "B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var hubOptions = new NatsOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Users = users,
|
||||||
|
LeafNode = new LeafNodeOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||||
|
var hubCts = new CancellationTokenSource();
|
||||||
|
_ = hub.StartAsync(hubCts.Token);
|
||||||
|
await hub.WaitForReadyAsync();
|
||||||
|
|
||||||
|
var spokeOptions = new NatsOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Users = users,
|
||||||
|
LeafNode = new LeafNodeOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Remotes = [hub.LeafListen!],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||||
|
var spokeCts = new CancellationTokenSource();
|
||||||
|
_ = spoke.StartAsync(spokeCts.Token);
|
||||||
|
await spoke.WaitForReadyAsync();
|
||||||
|
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||||
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||||
|
|
||||||
|
return new LeafAccountDeliveryFixture(hub, spoke, hubCts, spokeCts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NatsConnection> ConnectAsync(NatsServer server, string username)
|
||||||
|
{
|
||||||
|
var connection = new NatsConnection(new NatsOpts
|
||||||
|
{
|
||||||
|
Url = $"nats://{username}:pass@127.0.0.1:{server.Port}",
|
||||||
|
});
|
||||||
|
await connection.ConnectAsync();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForRemoteInterestOnHubAsync(string account, string subject)
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
while (!timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (Hub.HasRemoteInterest(account, subject))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _hubCts.CancelAsync();
|
||||||
|
await _spokeCts.CancelAsync();
|
||||||
|
Hub.Dispose();
|
||||||
|
Spoke.Dispose();
|
||||||
|
_hubCts.Dispose();
|
||||||
|
_spokeCts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using NATS.Server.LeafNodes;
|
||||||
|
using NATS.Server.Subscriptions;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.LeafNodes;
|
||||||
|
|
||||||
|
public class LeafInterestIdempotencyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Duplicate_RSplus_or_reconnect_replay_does_not_double_count_remote_interest()
|
||||||
|
{
|
||||||
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||||
|
using var leafSocket = await listener.AcceptSocketAsync();
|
||||||
|
await using var leaf = new LeafConnection(leafSocket);
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||||
|
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
|
||||||
|
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
|
||||||
|
await handshakeTask;
|
||||||
|
|
||||||
|
using var subList = new SubList();
|
||||||
|
var remoteAdded = 0;
|
||||||
|
subList.InterestChanged += change =>
|
||||||
|
{
|
||||||
|
if (change.Kind == InterestChangeKind.RemoteAdded)
|
||||||
|
remoteAdded++;
|
||||||
|
};
|
||||||
|
|
||||||
|
leaf.RemoteSubscriptionReceived = sub =>
|
||||||
|
{
|
||||||
|
subList.ApplyRemoteSub(sub);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
leaf.StartLoop(timeout.Token);
|
||||||
|
|
||||||
|
await WriteLineAsync(remoteSocket, "LS+ A orders.*", timeout.Token);
|
||||||
|
await WaitForAsync(() => subList.HasRemoteInterest("A", "orders.created"), timeout.Token);
|
||||||
|
|
||||||
|
await WriteLineAsync(remoteSocket, "LS+ A orders.*", timeout.Token);
|
||||||
|
await Task.Delay(100, timeout.Token);
|
||||||
|
|
||||||
|
subList.MatchRemote("A", "orders.created").Count.ShouldBe(1);
|
||||||
|
remoteAdded.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var bytes = new List<byte>(64);
|
||||||
|
var single = new byte[1];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||||
|
if (read == 0)
|
||||||
|
break;
|
||||||
|
if (single[0] == (byte)'\n')
|
||||||
|
break;
|
||||||
|
if (single[0] != (byte)'\r')
|
||||||
|
bytes.Add(single[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.ASCII.GetString([.. bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||||
|
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (predicate())
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Task.Delay(20, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Timed out waiting for condition.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using NATS.Server.LeafNodes;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.LeafNodes;
|
||||||
|
|
||||||
|
public class LeafLoopTransparencyRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Transport_internal_reply_and_loop_markers_never_leak_to_client_visible_subjects()
|
||||||
|
{
|
||||||
|
var nested = LeafLoopDetector.Mark(
|
||||||
|
LeafLoopDetector.Mark("orders.created", "S1"),
|
||||||
|
"S2");
|
||||||
|
|
||||||
|
LeafLoopDetector.TryUnmark(nested, out var unmarked).ShouldBeTrue();
|
||||||
|
unmarked.ShouldBe("orders.created");
|
||||||
|
unmarked.ShouldNotStartWith("$LDS.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,11 @@ internal sealed class PprofMonitorFixture : IAsyncDisposable
|
|||||||
return _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}{path}");
|
return _http.GetStringAsync($"http://127.0.0.1:{_monitorPort}{path}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> GetBytesAsync(string path)
|
||||||
|
{
|
||||||
|
return _http.GetByteArrayAsync($"http://127.0.0.1:{_monitorPort}{path}");
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_http.Dispose();
|
_http.Dispose();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Monitoring;
|
||||||
|
|
||||||
|
public class PprofRuntimeParityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Profiling_endpoint_returns_runtime_profile_artifacts_and_config_options_map_to_runtime_behavior()
|
||||||
|
{
|
||||||
|
await using var fx = await PprofMonitorFixture.StartWithProfilingAsync();
|
||||||
|
var payload = await fx.GetBytesAsync("/debug/pprof/profile?seconds=2");
|
||||||
|
var doc = JsonDocument.Parse(payload);
|
||||||
|
|
||||||
|
doc.RootElement.GetProperty("profile").GetString().ShouldBe("cpu");
|
||||||
|
doc.RootElement.GetProperty("seconds").GetInt32().ShouldBe(2);
|
||||||
|
doc.RootElement.GetProperty("thread_count").GetInt32().ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/NATS.Server.Tests/Mqtt/MqttAuthIntegrationTests.cs
Normal file
24
tests/NATS.Server.Tests/Mqtt/MqttAuthIntegrationTests.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Mqtt;
|
||||||
|
|
||||||
|
public class MqttAuthIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_mqtt_credentials_or_keepalive_timeout_close_session_with_protocol_error()
|
||||||
|
{
|
||||||
|
await using var listener = new MqttListener("127.0.0.1", 0, requiredUsername: "mqtt", requiredPassword: "secret");
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await listener.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(stream, "CONNECT auth-client user=bad pass=wrong");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("ERR mqtt auth failed");
|
||||||
|
(await MqttRuntimeWire.ReadRawAsync(stream, 1000)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/NATS.Server.Tests/Mqtt/MqttKeepAliveTests.cs
Normal file
26
tests/NATS.Server.Tests/Mqtt/MqttKeepAliveTests.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Mqtt;
|
||||||
|
|
||||||
|
public class MqttKeepAliveTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_mqtt_credentials_or_keepalive_timeout_close_session_with_protocol_error()
|
||||||
|
{
|
||||||
|
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await listener.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(stream, "CONNECT keepalive-client keepalive=1");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||||
|
|
||||||
|
await Task.Delay(2000);
|
||||||
|
(await MqttRuntimeWire.ReadRawAsync(stream, 1000)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/NATS.Server.Tests/Mqtt/MqttPacketParserTests.cs
Normal file
26
tests/NATS.Server.Tests/Mqtt/MqttPacketParserTests.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Mqtt;
|
||||||
|
|
||||||
|
public class MqttPacketParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Connect_packet_fixed_header_and_remaining_length_parse_correctly()
|
||||||
|
{
|
||||||
|
var packet = MqttPacketReader.Read(ConnectPacketBytes.Sample);
|
||||||
|
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||||
|
packet.RemainingLength.ShouldBe(12);
|
||||||
|
packet.Payload.Length.ShouldBe(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ConnectPacketBytes
|
||||||
|
{
|
||||||
|
public static readonly byte[] Sample =
|
||||||
|
[
|
||||||
|
0x10, 0x0C, // CONNECT + remaining length
|
||||||
|
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||||
|
0x04, 0x02, 0x00, 0x3C, // protocol level/flags/keepalive
|
||||||
|
0x00, 0x00, // empty client id
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/NATS.Server.Tests/Mqtt/MqttPacketWriterTests.cs
Normal file
20
tests/NATS.Server.Tests/Mqtt/MqttPacketWriterTests.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Mqtt;
|
||||||
|
|
||||||
|
public class MqttPacketWriterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Writer_emits_fixed_header_and_round_trips_with_reader()
|
||||||
|
{
|
||||||
|
byte[] payload = Enumerable.Repeat((byte)0xAB, 130).ToArray();
|
||||||
|
|
||||||
|
var encoded = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload);
|
||||||
|
encoded[0].ShouldBe((byte)0x30); // PUBLISH type with default flags
|
||||||
|
|
||||||
|
var decoded = MqttPacketReader.Read(encoded);
|
||||||
|
decoded.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||||
|
decoded.RemainingLength.ShouldBe(payload.Length);
|
||||||
|
decoded.Payload.ToArray().ShouldBe(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/NATS.Server.Tests/Mqtt/MqttQosAckRuntimeTests.cs
Normal file
26
tests/NATS.Server.Tests/Mqtt/MqttQosAckRuntimeTests.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Mqtt;
|
||||||
|
|
||||||
|
public class MqttQosAckRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Qos1_publish_receives_puback_and_redelivery_on_session_reconnect_when_unacked()
|
||||||
|
{
|
||||||
|
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await listener.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||||
|
var stream = client.GetStream();
|
||||||
|
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(stream, "CONNECT qos-client clean=false");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||||
|
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(stream, "PUBQ1 7 sensors.temp 42");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 7");
|
||||||
|
}
|
||||||
|
}
|
||||||
89
tests/NATS.Server.Tests/Mqtt/MqttSessionRuntimeTests.cs
Normal file
89
tests/NATS.Server.Tests/Mqtt/MqttSessionRuntimeTests.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using NATS.Server.Mqtt;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Mqtt;
|
||||||
|
|
||||||
|
public class MqttSessionRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Qos1_publish_receives_puback_and_redelivery_on_session_reconnect_when_unacked()
|
||||||
|
{
|
||||||
|
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await listener.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
using (var first = new TcpClient())
|
||||||
|
{
|
||||||
|
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||||
|
var firstStream = first.GetStream();
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(firstStream, "CONNECT session-client clean=false");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(firstStream, 1000)).ShouldBe("CONNACK");
|
||||||
|
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(firstStream, "PUBQ1 21 sensors.temp 99");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(firstStream, 1000)).ShouldBe("PUBACK 21");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var second = new TcpClient();
|
||||||
|
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||||
|
var secondStream = second.GetStream();
|
||||||
|
await MqttRuntimeWire.WriteLineAsync(secondStream, "CONNECT session-client clean=false");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(secondStream, 1000)).ShouldBe("CONNACK");
|
||||||
|
(await MqttRuntimeWire.ReadLineAsync(secondStream, 1000)).ShouldBe("REDLIVER 21 sensors.temp 99");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class MqttRuntimeWire
|
||||||
|
{
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> ReadRawAsync(NetworkStream stream, int timeoutMs)
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||||
|
var one = new byte[1];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||||
|
if (read == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(one, 0, read);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return "__timeout__";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/NATS.Server.Tests/Parity/NatsCapabilityInventory.cs
Normal file
52
tests/NATS.Server.Tests/Parity/NatsCapabilityInventory.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
namespace NATS.Server.Tests.Parity;
|
||||||
|
|
||||||
|
public sealed record CapabilityRow(string Capability, string Behavior, string Tests, string Docs);
|
||||||
|
|
||||||
|
public sealed class NatsCapabilityInventoryReport
|
||||||
|
{
|
||||||
|
public NatsCapabilityInventoryReport(IReadOnlyList<CapabilityRow> rows)
|
||||||
|
{
|
||||||
|
Rows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<CapabilityRow> Rows { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<CapabilityRow> InvalidRows => Rows
|
||||||
|
.Where(r => !IsDone(r.Behavior) && IsClosed(r.Docs))
|
||||||
|
.Concat(Rows.Where(r => !IsDone(r.Tests) && IsClosed(r.Docs)))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static bool IsDone(string status) => string.Equals(status, "done", StringComparison.OrdinalIgnoreCase);
|
||||||
|
private static bool IsClosed(string status) => string.Equals(status, "closed", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatsCapabilityInventory
|
||||||
|
{
|
||||||
|
public static NatsCapabilityInventoryReport Load(string relativePath)
|
||||||
|
{
|
||||||
|
var repositoryRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||||
|
var mapPath = Path.Combine(repositoryRoot, relativePath);
|
||||||
|
File.Exists(mapPath).ShouldBeTrue();
|
||||||
|
|
||||||
|
var rows = new List<CapabilityRow>();
|
||||||
|
foreach (var rawLine in File.ReadLines(mapPath))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (!line.StartsWith("|", StringComparison.Ordinal) || line.Contains("---", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var cells = line.Trim('|').Split('|').Select(static c => c.Trim()).ToArray();
|
||||||
|
if (cells.Length < 4 || string.Equals(cells[0], "Capability", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
rows.Add(new CapabilityRow(
|
||||||
|
cells[0],
|
||||||
|
cells[1],
|
||||||
|
cells[2],
|
||||||
|
cells[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NatsCapabilityInventoryReport(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace NATS.Server.Tests.Parity;
|
||||||
|
|
||||||
|
public class NatsStrictCapabilityInventoryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Strict_capability_inventory_has_no_open_items_marked_done_without_behavior_and_tests()
|
||||||
|
{
|
||||||
|
var report = NatsCapabilityInventory.Load(
|
||||||
|
"docs/plans/2026-02-23-nats-strict-full-go-parity-map.md");
|
||||||
|
report.InvalidRows.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using NATS.Server.Raft;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Raft;
|
||||||
|
|
||||||
|
public class RaftStrictConsensusRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Quorum_and_nextindex_rules_gate_commit_visibility_and_snapshot_catchup_convergence()
|
||||||
|
{
|
||||||
|
var voter = new RaftNode("v1");
|
||||||
|
voter.GrantVote(2, "cand-a").Granted.ShouldBeTrue();
|
||||||
|
voter.GrantVote(2, "cand-b").Granted.ShouldBeFalse();
|
||||||
|
|
||||||
|
var transport = new RejectingRaftTransport();
|
||||||
|
var leader = new RaftNode("n1", transport);
|
||||||
|
var followerA = new RaftNode("n2", transport);
|
||||||
|
var followerB = new RaftNode("n3", transport);
|
||||||
|
var cluster = new[] { leader, followerA, followerB };
|
||||||
|
foreach (var node in cluster)
|
||||||
|
node.ConfigureCluster(cluster);
|
||||||
|
|
||||||
|
leader.StartElection(cluster.Length);
|
||||||
|
leader.ReceiveVote(new VoteResponse { Granted = true }, cluster.Length);
|
||||||
|
leader.IsLeader.ShouldBeTrue();
|
||||||
|
|
||||||
|
_ = await leader.ProposeAsync("cmd-1", default);
|
||||||
|
leader.AppliedIndex.ShouldBe(0);
|
||||||
|
followerA.AppliedIndex.ShouldBe(0);
|
||||||
|
followerB.AppliedIndex.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RejectingRaftTransport : IRaftTransport
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<AppendResult>> AppendEntriesAsync(string leaderId, IReadOnlyList<string> followerIds, RaftLogEntry entry, CancellationToken ct)
|
||||||
|
=> Task.FromResult<IReadOnlyList<AppendResult>>(
|
||||||
|
followerIds.Select(id => new AppendResult { FollowerId = id, Success = false }).ToArray());
|
||||||
|
|
||||||
|
public Task<VoteResponse> RequestVoteAsync(string candidateId, string voterId, VoteRequest request, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new VoteResponse { Granted = true });
|
||||||
|
|
||||||
|
public Task InstallSnapshotAsync(string leaderId, string followerId, RaftSnapshot snapshot, CancellationToken ct)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using NATS.Server.Raft;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Raft;
|
||||||
|
|
||||||
|
public class RaftStrictConvergenceRuntimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Quorum_and_nextindex_rules_gate_commit_visibility_and_snapshot_catchup_convergence()
|
||||||
|
{
|
||||||
|
var file = Path.Combine(Path.GetTempPath(), $"nats-raft-snapshot-{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var first = new RaftSnapshotStore(file);
|
||||||
|
await first.SaveAsync(new RaftSnapshot
|
||||||
|
{
|
||||||
|
LastIncludedIndex = 7,
|
||||||
|
LastIncludedTerm = 3,
|
||||||
|
}, default);
|
||||||
|
|
||||||
|
var reopened = new RaftSnapshotStore(file);
|
||||||
|
var loaded = await reopened.LoadAsync(default);
|
||||||
|
loaded.ShouldNotBeNull();
|
||||||
|
loaded.LastIncludedIndex.ShouldBe(7);
|
||||||
|
loaded.LastIncludedTerm.ShouldBe(3);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(file))
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NATS.Client.Core;
|
||||||
|
using NATS.Server.Auth;
|
||||||
|
using NATS.Server.Configuration;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Routes;
|
||||||
|
|
||||||
|
public class RouteAccountScopedDeliveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Remote_message_delivery_uses_target_account_sublist_not_global_sublist()
|
||||||
|
{
|
||||||
|
const string subject = "orders.created";
|
||||||
|
await using var fixture = await RouteAccountDeliveryFixture.StartAsync();
|
||||||
|
|
||||||
|
await using var remoteAccountA = await fixture.ConnectAsync(fixture.ServerB, "a_sub");
|
||||||
|
await using var remoteAccountB = await fixture.ConnectAsync(fixture.ServerB, "b_sub");
|
||||||
|
await using var publisher = await fixture.ConnectAsync(fixture.ServerA, "a_pub");
|
||||||
|
|
||||||
|
await using var subA = await remoteAccountA.SubscribeCoreAsync<string>(subject);
|
||||||
|
await using var subB = await remoteAccountB.SubscribeCoreAsync<string>(subject);
|
||||||
|
await remoteAccountA.PingAsync();
|
||||||
|
await remoteAccountB.PingAsync();
|
||||||
|
await fixture.WaitForRemoteInterestOnServerAAsync("A", subject);
|
||||||
|
|
||||||
|
await publisher.PublishAsync(subject, "from-route-a");
|
||||||
|
|
||||||
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
var msgA = await subA.Msgs.ReadAsync(receiveTimeout.Token);
|
||||||
|
msgA.Data.ShouldBe("from-route-a");
|
||||||
|
|
||||||
|
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||||
|
await subB.Msgs.ReadAsync(leakTimeout.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RouteAccountDeliveryFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _ctsA;
|
||||||
|
private readonly CancellationTokenSource _ctsB;
|
||||||
|
|
||||||
|
private RouteAccountDeliveryFixture(NatsServer serverA, NatsServer serverB, CancellationTokenSource ctsA, CancellationTokenSource ctsB)
|
||||||
|
{
|
||||||
|
ServerA = serverA;
|
||||||
|
ServerB = serverB;
|
||||||
|
_ctsA = ctsA;
|
||||||
|
_ctsB = ctsB;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NatsServer ServerA { get; }
|
||||||
|
public NatsServer ServerB { get; }
|
||||||
|
|
||||||
|
public static async Task<RouteAccountDeliveryFixture> StartAsync()
|
||||||
|
{
|
||||||
|
var users = new User[]
|
||||||
|
{
|
||||||
|
new() { Username = "a_pub", Password = "pass", Account = "A" },
|
||||||
|
new() { Username = "a_sub", Password = "pass", Account = "A" },
|
||||||
|
new() { Username = "b_sub", Password = "pass", Account = "B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var optsA = new NatsOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Users = users,
|
||||||
|
Cluster = new ClusterOptions
|
||||||
|
{
|
||||||
|
Name = Guid.NewGuid().ToString("N"),
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||||
|
var ctsA = new CancellationTokenSource();
|
||||||
|
_ = serverA.StartAsync(ctsA.Token);
|
||||||
|
await serverA.WaitForReadyAsync();
|
||||||
|
|
||||||
|
var optsB = new NatsOptions
|
||||||
|
{
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Users = users,
|
||||||
|
Cluster = new ClusterOptions
|
||||||
|
{
|
||||||
|
Name = Guid.NewGuid().ToString("N"),
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 0,
|
||||||
|
Routes = [serverA.ClusterListen!],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||||
|
var ctsB = new CancellationTokenSource();
|
||||||
|
_ = serverB.StartAsync(ctsB.Token);
|
||||||
|
await serverB.WaitForReadyAsync();
|
||||||
|
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
while (!timeout.IsCancellationRequested && (serverA.Stats.Routes == 0 || serverB.Stats.Routes == 0))
|
||||||
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||||
|
|
||||||
|
return new RouteAccountDeliveryFixture(serverA, serverB, ctsA, ctsB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NatsConnection> ConnectAsync(NatsServer server, string username)
|
||||||
|
{
|
||||||
|
var connection = new NatsConnection(new NatsOpts
|
||||||
|
{
|
||||||
|
Url = $"nats://{username}:pass@127.0.0.1:{server.Port}",
|
||||||
|
});
|
||||||
|
await connection.ConnectAsync();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForRemoteInterestOnServerAAsync(string account, string subject)
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
while (!timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (ServerA.HasRemoteInterest(account, subject))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _ctsA.CancelAsync();
|
||||||
|
await _ctsB.CancelAsync();
|
||||||
|
ServerA.Dispose();
|
||||||
|
ServerB.Dispose();
|
||||||
|
_ctsA.Dispose();
|
||||||
|
_ctsB.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using NATS.Server.Routes;
|
||||||
|
using NATS.Server.Subscriptions;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Routes;
|
||||||
|
|
||||||
|
public class RouteInterestIdempotencyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Duplicate_RSplus_or_reconnect_replay_does_not_double_count_remote_interest()
|
||||||
|
{
|
||||||
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||||
|
using var routeSocket = await listener.AcceptSocketAsync();
|
||||||
|
await using var route = new RouteConnection(routeSocket);
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||||
|
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("ROUTE LOCAL");
|
||||||
|
await WriteLineAsync(remoteSocket, "ROUTE REMOTE", timeout.Token);
|
||||||
|
await handshakeTask;
|
||||||
|
|
||||||
|
using var subList = new SubList();
|
||||||
|
var remoteAdded = 0;
|
||||||
|
subList.InterestChanged += change =>
|
||||||
|
{
|
||||||
|
if (change.Kind == InterestChangeKind.RemoteAdded)
|
||||||
|
remoteAdded++;
|
||||||
|
};
|
||||||
|
|
||||||
|
route.RemoteSubscriptionReceived = sub =>
|
||||||
|
{
|
||||||
|
subList.ApplyRemoteSub(sub);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
route.StartFrameLoop(timeout.Token);
|
||||||
|
|
||||||
|
await WriteLineAsync(remoteSocket, "RS+ A orders.*", timeout.Token);
|
||||||
|
await WaitForAsync(() => subList.HasRemoteInterest("A", "orders.created"), timeout.Token);
|
||||||
|
|
||||||
|
await WriteLineAsync(remoteSocket, "RS+ A orders.*", timeout.Token);
|
||||||
|
await Task.Delay(100, timeout.Token);
|
||||||
|
|
||||||
|
subList.MatchRemote("A", "orders.created").Count.ShouldBe(1);
|
||||||
|
remoteAdded.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var bytes = new List<byte>(64);
|
||||||
|
var single = new byte[1];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||||
|
if (read == 0)
|
||||||
|
break;
|
||||||
|
if (single[0] == (byte)'\n')
|
||||||
|
break;
|
||||||
|
if (single[0] != (byte)'\r')
|
||||||
|
bytes.Add(single[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.ASCII.GetString([.. bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||||
|
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (predicate())
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Task.Delay(20, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Timed out waiting for condition.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user