Compare commits
64 Commits
11b387e442
...
codex/stub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8849265780 | ||
|
|
ba4f41cf71 | ||
|
|
4e61314c1c | ||
|
|
db1de2a384 | ||
|
|
7a338dd510 | ||
|
|
3297334261 | ||
|
|
4972f998b7 | ||
|
|
7518b97b79 | ||
|
|
485c7b0c2e | ||
|
|
9e2d763741 | ||
|
|
0c9eb2a06c | ||
|
|
a91cfbc7bd | ||
|
|
26d6d7fe68 | ||
|
|
0436e08fc1 | ||
|
|
2dd23211c7 | ||
|
|
c5c6fbc027 | ||
|
|
84dc9d1e1d | ||
|
|
60dce2dc9a | ||
|
|
e7f259710a | ||
|
|
810ef29dbb | ||
|
|
01df4ccff3 | ||
|
|
4ba6b2642e | ||
|
|
21bb760e63 | ||
|
|
4901249511 | ||
|
|
7769966e2e | ||
|
|
750916caed | ||
|
|
b63f66fbdc | ||
|
|
2a900bf56a | ||
|
|
0a6e6bf60d | ||
|
|
3f6c5f243d | ||
|
|
a99092d0bd | ||
|
|
97be7a25a2 | ||
|
|
11ec33da53 | ||
|
|
1c5921d2c1 | ||
|
|
3e35ffadce | ||
|
|
6a1df6b6f8 | ||
|
|
9552f6e7e9 | ||
|
|
f0faaffe69 | ||
|
|
6e90eea736 | ||
|
|
0950580967 | ||
|
|
917cd33442 | ||
|
|
364329cc1e | ||
|
|
91f86b9f51 | ||
|
|
f0b4138459 | ||
|
|
8b63a6f6c2 | ||
|
|
08620388f1 | ||
|
|
7750b46f9f | ||
|
|
d09de1c5cf | ||
|
|
a0c9c0094c | ||
|
|
8c380e7ca6 | ||
|
|
aa1fb5ac4e | ||
|
|
9c1ffc0995 | ||
|
|
8253f975ec | ||
|
|
63715f256a | ||
|
|
a58e8e2572 | ||
|
|
e6bc76b315 | ||
|
|
84d450b4a0 | ||
|
|
3cffa5b156 | ||
|
|
5a2c8a3250 | ||
|
|
77403e3d31 | ||
|
|
ce45dff994 | ||
|
|
12a14ec476 | ||
|
|
06779a1f77 | ||
|
|
0df93c23b0 |
228
AGENTS.md
Normal file
228
AGENTS.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Summary
|
||||
|
||||
This project ports the NATS messaging server from Go to .NET 10 C#. The Go source (~130K LOC) is the reference at `golang/nats-server/`. Porting progress is tracked in an SQLite database (`porting.db`) managed by the PortTracker CLI tool.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
```
|
||||
natsnet/
|
||||
├── golang/nats-server/ # Go source (read-only reference)
|
||||
├── dotnet/
|
||||
│ ├── src/ZB.MOM.NatsNet.Server/ # Main server library
|
||||
│ ├── src/ZB.MOM.NatsNet.Server.Host/ # Host entry point
|
||||
│ └── tests/
|
||||
│ ├── ZB.MOM.NatsNet.Server.Tests/ # Unit tests
|
||||
│ └── ZB.MOM.NatsNet.Server.IntegrationTests/ # Integration tests
|
||||
├── tools/NatsNet.PortTracker/ # CLI tracking tool
|
||||
├── docs/standards/dotnet-standards.md # .NET coding standards (MUST follow)
|
||||
├── docs/plans/phases/ # Phase instruction guides
|
||||
├── reports/current.md # Latest porting status
|
||||
├── porting.db # SQLite tracking database
|
||||
└── porting-schema.sql # Database schema
|
||||
```
|
||||
|
||||
## Build and Test
|
||||
|
||||
```bash
|
||||
# Build the solution
|
||||
dotnet build dotnet/
|
||||
|
||||
# Run all unit tests
|
||||
dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
|
||||
|
||||
# Run filtered tests (by namespace/class)
|
||||
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol" \
|
||||
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
|
||||
|
||||
# Run integration tests
|
||||
dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/
|
||||
|
||||
# Generate porting report
|
||||
./reports/generate-report.sh
|
||||
```
|
||||
|
||||
## .NET Coding Standards
|
||||
|
||||
**MUST follow all rules in `docs/standards/dotnet-standards.md`.**
|
||||
|
||||
Critical rules (non-negotiable):
|
||||
|
||||
- .NET 10, C# latest, nullable enabled
|
||||
- **xUnit 3** + **Shouldly** + **NSubstitute** for testing
|
||||
- **NEVER use FluentAssertions or Moq** — these are forbidden
|
||||
- PascalCase for public members, `_camelCase` for private fields
|
||||
- File-scoped namespaces: `ZB.MOM.NatsNet.Server.[Module]`
|
||||
- Use `CancellationToken` on all async signatures
|
||||
- Use `ReadOnlySpan<byte>` on hot paths
|
||||
- Test naming: `[Method]_[Scenario]_[Expected]`
|
||||
- Test class naming: `[ClassName]Tests`
|
||||
- Structured logging with `ILogger<T>` and `LogContext.PushProperty`
|
||||
|
||||
## PortTracker CLI
|
||||
|
||||
All tracking commands use this base:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- <command> --db porting.db
|
||||
```
|
||||
|
||||
### Querying
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `report summary` | Show overall porting progress |
|
||||
| `dependency ready` | List items ready to port (no unported deps) |
|
||||
| `dependency blocked` | List items blocked by unported deps |
|
||||
| `feature list --status <s>` | List features by status |
|
||||
| `feature list --module <id>` | List features in a module |
|
||||
| `feature show <id>` | Show feature details (Go source path, .NET target) |
|
||||
| `test list --status <s>` | List tests by status |
|
||||
| `test show <id>` | Show test details |
|
||||
| `module list` | List all modules |
|
||||
| `module show <id>` | Show module with its features and tests |
|
||||
|
||||
### Updating Status
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `feature update <id> --status <s>` | Update one feature |
|
||||
| `feature batch-update --ids "1-10" --set-status <s> --execute` | Bulk update features |
|
||||
| `test update <id> --status <s>` | Update one test |
|
||||
| `test batch-update --ids "1-10" --set-status <s> --execute` | Bulk update tests |
|
||||
| `module update <id> --status <s>` | Update module status |
|
||||
|
||||
### Audit Verification
|
||||
|
||||
Status updates are verified against Roslyn audit results. If the audit disagrees with your requested status, add `--override "reason"` to force it.
|
||||
|
||||
```bash
|
||||
feature update 42 --status verified --override "manually verified logic"
|
||||
```
|
||||
|
||||
### Audit Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `audit --type features` | Dry-run audit of features against .NET source |
|
||||
| `audit --type tests` | Dry-run audit of tests against test project |
|
||||
| `audit --type features --execute` | Apply audit classifications to DB |
|
||||
| `audit --type tests --execute` | Apply test audit classifications to DB |
|
||||
|
||||
### Valid Statuses
|
||||
|
||||
```
|
||||
not_started → stub → complete → verified
|
||||
└→ n_a (not applicable)
|
||||
└→ deferred (blocked, needs server infra)
|
||||
```
|
||||
|
||||
### Batch ID Syntax
|
||||
|
||||
`--ids` accepts: ranges `"100-200"`, lists `"1,5,10"`, or mixed `"1-5,10,20-25"`.
|
||||
|
||||
All batch commands default to dry-run. Add `--execute` to apply.
|
||||
|
||||
## Porting Workflow
|
||||
|
||||
### Finding Work
|
||||
|
||||
1. Query for features ready to port:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
|
||||
```
|
||||
|
||||
2. Or find deferred/stub features in a specific module:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module <id> --status deferred --db porting.db
|
||||
```
|
||||
|
||||
3. To find tests that need implementing:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- test list --status stub --db porting.db
|
||||
dotnet run --project tools/NatsNet.PortTracker -- test list --status deferred --db porting.db
|
||||
```
|
||||
|
||||
### Implementing a Feature
|
||||
|
||||
1. **Claim it** — mark as stub before starting:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status stub --db porting.db
|
||||
```
|
||||
|
||||
2. **Read the Go source** — use `feature show <id>` to get the Go file path and line numbers, then read the Go implementation.
|
||||
|
||||
3. **Write idiomatic C#** — translate intent, not lines:
|
||||
- Use `async`/`await`, not goroutine translations
|
||||
- Use `Channel<T>` for Go channels
|
||||
- Use `CancellationToken` for `context.Context`
|
||||
- Use `ReadOnlySpan<byte>` on hot paths
|
||||
- Use `Lock` (C# 13) for `sync.Mutex`
|
||||
- Use `ReaderWriterLockSlim` for `sync.RWMutex`
|
||||
|
||||
4. **Ensure it compiles** — run `dotnet build dotnet/`
|
||||
|
||||
5. **Mark complete**:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status complete --db porting.db
|
||||
```
|
||||
|
||||
### Implementing a Unit Test
|
||||
|
||||
1. **Read the Go test** — use `test show <id>` to get Go source location.
|
||||
2. **Read the corresponding .NET feature** to understand the API surface.
|
||||
3. **Write the test** in `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/` using xUnit 3 + Shouldly + NSubstitute.
|
||||
4. **Run it**:
|
||||
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~TestClassName" \
|
||||
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
|
||||
```
|
||||
|
||||
5. **Mark verified** (if passing):
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- test update <id> --status verified --db porting.db
|
||||
```
|
||||
|
||||
### After Completing Work
|
||||
|
||||
1. Run affected tests to verify nothing broke.
|
||||
2. Update DB status for all items you changed.
|
||||
3. Check what's newly unblocked:
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
|
||||
```
|
||||
|
||||
4. Generate updated report:
|
||||
|
||||
```bash
|
||||
./reports/generate-report.sh
|
||||
```
|
||||
|
||||
## Go to .NET Translation Reference
|
||||
|
||||
| Go Pattern | .NET Equivalent |
|
||||
|------------|-----------------|
|
||||
| `goroutine` | `Task.Run` or `async`/`await` |
|
||||
| `chan T` | `Channel<T>` |
|
||||
| `select` | `Task.WhenAny` |
|
||||
| `sync.Mutex` | `Lock` (C# 13) |
|
||||
| `sync.RWMutex` | `ReaderWriterLockSlim` |
|
||||
| `sync.WaitGroup` | `Task.WhenAll` or `CountdownEvent` |
|
||||
| `atomic.Int64` | `Interlocked` methods on `long` field |
|
||||
| `context.Context` | `CancellationToken` |
|
||||
| `defer` | `try`/`finally` or `using` |
|
||||
| `error` return | Exceptions or Result pattern |
|
||||
| `[]byte` | `byte[]`, `ReadOnlySpan<byte>`, `ReadOnlyMemory<byte>` |
|
||||
| `map[K]V` | `Dictionary<K,V>` or `ConcurrentDictionary<K,V>` |
|
||||
| `interface{}` | `object` or generics |
|
||||
| `time.Duration` | `TimeSpan` |
|
||||
| `weak.Pointer[T]` | `WeakReference<T>` |
|
||||
1644
docs/plans/2026-02-26-complete-stub-features.md
Normal file
1644
docs/plans/2026-02-26-complete-stub-features.md
Normal file
File diff suppressed because it is too large
Load Diff
33
docs/plans/2026-02-26-complete-stub-features.md.tasks.json
Normal file
33
docs/plans/2026-02-26-complete-stub-features.md.tasks.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-26-complete-stub-features.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Session A — Config binding (67 stubs)",
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
"A1: Add JsonPropertyName attrs to ServerOptions.cs",
|
||||
"A2: Create Config/NatsJsonConverters.cs",
|
||||
"A3: Create Config/ServerOptionsConfiguration.cs",
|
||||
"A4: Write Config/ServerOptionsConfigurationTests.cs",
|
||||
"A5: DB update + commit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Session B — Auth implementation (26 stubs)",
|
||||
"status": "pending",
|
||||
"subtasks": [
|
||||
"B1: Add NATS.NKeys NuGet package",
|
||||
"B2: Add operator JWT methods to JwtProcessor.cs",
|
||||
"B3: Add auth helper methods to AuthHandler.cs",
|
||||
"B4: Create NatsServer.Auth.cs",
|
||||
"B5: Create Auth/AuthCallout.cs",
|
||||
"B6: Create NatsServer.Signals.cs",
|
||||
"B7: Write Auth/AuthImplementationTests.cs",
|
||||
"B8: DB update + commit"
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-02-26T00:00:00Z"
|
||||
}
|
||||
144
docs/plans/2026-02-26-phase7-design.md
Normal file
144
docs/plans/2026-02-26-phase7-design.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Phase 7: Porting Verification — Design
|
||||
|
||||
**Date:** 2026-02-26
|
||||
**Scope:** Verify all ported code through targeted testing; mark server-integration tests as `deferred`
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
After Phase 6 (23 porting sessions + 93 stub completions), the DB state entering Phase 7:
|
||||
|
||||
| Item | Count |
|
||||
|------|-------|
|
||||
| Features complete | 3,596 / 3,673 (77 n_a) |
|
||||
| Unit tests complete | 319 |
|
||||
| Unit tests stub | 224 |
|
||||
| Unit tests not_started | 2,533 |
|
||||
| Unit tests n_a | 181 |
|
||||
| Unit tests total | 3,257 |
|
||||
|
||||
635 unit tests currently pass. 166 `NotImplementedException` stubs remain in the server — the .NET server is not yet runnable end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decision: Two Test Layers
|
||||
|
||||
Go test files (`jetstream_test.go`, `monitor_test.go`, etc.) all use `RunBasicJetStreamServer()` / `RunServer()` — they start a real NATS server over TCP, then connect via NATS client. These are server-integration tests regardless of whether they target a single node or a cluster.
|
||||
|
||||
| Layer | Tests | Treatment |
|
||||
|-------|-------|-----------|
|
||||
| **Unit** | Pure component logic (no server startup) | Port & verify in Phase 7 |
|
||||
| **Server-integration** | Require running NatsServer + NATS client | Status `deferred` |
|
||||
|
||||
---
|
||||
|
||||
## Schema Extension
|
||||
|
||||
Add `deferred` to the `unit_tests.status` CHECK constraint:
|
||||
|
||||
```sql
|
||||
-- Migration: add 'deferred' to unit_tests status enum
|
||||
-- Recreate table with updated constraint or use SQLite trigger workaround
|
||||
```
|
||||
|
||||
`deferred` = test blocked on running server or cluster infrastructure. Distinct from `n_a` (not applicable to this port).
|
||||
|
||||
---
|
||||
|
||||
## Test Classification
|
||||
|
||||
### Unit Tests to Port (~631 new tests)
|
||||
|
||||
| Go source file | Not-started / Stub | Component |
|
||||
|---|---|---|
|
||||
| `opts_test.go` | 77 stubs + remaining | Config parsing / binding |
|
||||
| `jwt_test.go` | 88 stubs | JWT decode / validate |
|
||||
| `auth_test.go` | 6 stubs | Auth handler logic |
|
||||
| `auth_callout_test.go` | 31 stubs | Auth callout types / helpers |
|
||||
| `signal_test.go` | 16 stubs | Signal handler registration |
|
||||
| `log_test.go` | 3 stubs | Logger behaviour |
|
||||
| `config_check_test.go` | 3 stubs | Config validation |
|
||||
| `memstore_test.go` | 41 not_started | Memory store logic |
|
||||
| `store_test.go` | 17 not_started | Store interface contract |
|
||||
| `filestore_test.go` | 249 not_started | File store read/write/purge |
|
||||
| `jetstream_errors_test.go` | 4 not_started | Error type checks |
|
||||
| `jetstream_versioning_test.go` | 18 not_started | Version compatibility |
|
||||
| `jetstream_batching_test.go` | 29 not_started | Batching logic |
|
||||
| `dirstore_test.go` | 12 not_started | JWT directory store |
|
||||
| `accounts_test.go` | 31 not_started | Account logic (unit subset) |
|
||||
| `thw` module | 6 not_started | Time hash wheel |
|
||||
|
||||
### Server-Integration Tests → `deferred` (~1,799 tests)
|
||||
|
||||
| Go source file | Count | Deferred reason |
|
||||
|---|---|---|
|
||||
| `jetstream_test.go` | 320 | Needs running server |
|
||||
| `jetstream_consumer_test.go` | 161 | Needs running server |
|
||||
| `monitor_test.go` | 103 | HTTP monitoring endpoints |
|
||||
| `reload_test.go` | 73 | Live config reload |
|
||||
| `routes_test.go` | 70 | Multi-server routing |
|
||||
| `events_test.go` | 52 | Server event bus |
|
||||
| `server_test.go` | 20 | Server lifecycle |
|
||||
| `jetstream_cluster_*` (×4) | 456 | Multi-node cluster |
|
||||
| `mqtt_test.go` + extras | ~162 | MQTT server |
|
||||
| `websocket_test.go` | 109 | WebSocket server |
|
||||
| `raft_test.go` | 104 | Raft consensus |
|
||||
| `leafnode_test.go` + proxy | 120 | Leaf node infrastructure |
|
||||
| `gateway_test.go` | 88 | Gateway infrastructure |
|
||||
| `jetstream_super_cluster_test.go` | 47 | Super-cluster |
|
||||
| `norace_*` tests | ~141 | Race-detector / timing |
|
||||
| Benchmark tests | ~20 | Performance only |
|
||||
| Other cluster/misc | ~53 | Cluster infrastructure |
|
||||
|
||||
---
|
||||
|
||||
## Session Structure (10 sessions)
|
||||
|
||||
| Session | Scope | New tests | Source files |
|
||||
|---------|-------|-----------|---|
|
||||
| **P7-01** | Schema migration + small module verification | 0 new (114 existing) | ats, avl, certidp, gsl, pse, stree, thw, tpm |
|
||||
| **P7-02** | Opts & config stubs + remaining opts tests | ~95 | `opts_test.go` |
|
||||
| **P7-03** | JWT stubs | 88 | `jwt_test.go` |
|
||||
| **P7-04** | Auth stubs + auth callout stubs | 37 | `auth_test.go`, `auth_callout_test.go`, `config_check_test.go` |
|
||||
| **P7-05** | Signal + log stubs | 19 | `signal_test.go`, `log_test.go` |
|
||||
| **P7-06** | Store unit tests — memory + interface | ~58 | `memstore_test.go`, `store_test.go` |
|
||||
| **P7-07** | File store unit tests (first half) | ~125 | `filestore_test.go` lines 1–~4,000 |
|
||||
| **P7-08** | File store unit tests (second half) | ~124 | `filestore_test.go` remainder |
|
||||
| **P7-09** | JetStream unit tests — errors, versioning, batching, dirstore, accounts | ~94 | `jetstream_errors_test.go`, `jetstream_versioning_test.go`, `jetstream_batching_test.go`, `dirstore_test.go`, `accounts_test.go` |
|
||||
| **P7-10** | Mark deferred, integration tests, DB final update, Phase 7 close | — | DB sweep + Gitea milestones 7 & 8 |
|
||||
|
||||
**Total new tests written: ~640**
|
||||
|
||||
---
|
||||
|
||||
## Verification Flow (per session)
|
||||
|
||||
1. Write / fill tests → build → run → confirm green
|
||||
2. Mark tests `complete` in DB (new tests) then `verified`
|
||||
3. Mark small modules `verified` in DB (P7-01); server module at P7-10
|
||||
4. `./reports/generate-report.sh` → commit
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests (P7-10)
|
||||
|
||||
Replace the placeholder `UnitTest1.cs` with `NatsServerBehaviorTests.cs`. Tests run against the **Go NATS server** (not the .NET server) to establish a behavioral baseline:
|
||||
|
||||
- Basic pub/sub
|
||||
- Wildcard matching (`foo.*`, `foo.>`)
|
||||
- Queue groups
|
||||
- Connect/disconnect lifecycle
|
||||
- Protocol error handling
|
||||
|
||||
---
|
||||
|
||||
## Completion Definition
|
||||
|
||||
Phase 7 is complete when:
|
||||
- All non-`n_a`, non-`deferred` tests are `verified`
|
||||
- `dotnet run --project tools/NatsNet.PortTracker -- phase check 7 --db porting.db` passes
|
||||
- Gitea issues #45–#52 closed
|
||||
- Gitea milestones 7 and 8 closed
|
||||
|
||||
The ~1,799 `deferred` tests remain for a future phase once the .NET server is end-to-end runnable.
|
||||
1324
docs/plans/2026-02-26-phase7-plan.md
Normal file
1324
docs/plans/2026-02-26-phase7-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
17
docs/plans/2026-02-26-phase7-plan.md.tasks.json
Normal file
17
docs/plans/2026-02-26-phase7-plan.md.tasks.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-26-phase7-plan.md",
|
||||
"tasks": [
|
||||
{"id": 13, "subject": "Task 1: Schema Migration — Add deferred status", "status": "pending"},
|
||||
{"id": 14, "subject": "Task 2: P7-01 — Small module verification (114 tests)", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 3: P7-02 — Opts stubs (77 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 4: P7-03 — JWT stubs (88 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 17, "subject": "Task 5: P7-04 — Auth & config-check stubs (40 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 18, "subject": "Task 6: P7-05 — Signal & log stubs (19 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 19, "subject": "Task 7: P7-06 — Memory store & store interface tests (58 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 20, "subject": "Task 8: P7-07 — File store tests, first half (~125 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 21, "subject": "Task 9: P7-08 — File store tests, second half (~124 tests)", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 10: P7-09 — JetStream unit tests (94 tests)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 23, "subject": "Task 11: P7-10 — Mark deferred, integration tests, close phase", "status": "pending", "blockedBy": [15, 16, 17, 18, 19, 21, 22]}
|
||||
],
|
||||
"lastUpdated": "2026-02-26T00:00:00Z"
|
||||
}
|
||||
185
docs/plans/2026-02-26-stub-features-design.md
Normal file
185
docs/plans/2026-02-26-stub-features-design.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Stub Features Implementation Design
|
||||
|
||||
**Date:** 2026-02-26
|
||||
**Scope:** Complete the 93 remaining `stub` features in Phase 6
|
||||
**Approach:** Two parallel sessions (Config + Auth)
|
||||
|
||||
## Overview
|
||||
|
||||
After Phase 6's 23 porting sessions, 93 features remain at `stub` status. They fall into two
|
||||
independent concerns that can be implemented in parallel:
|
||||
|
||||
| Group | Go File | Stubs | Go LOC | Concern |
|
||||
|-------|---------|-------|--------|---------|
|
||||
| Config | `server/opts.go` | 67 | ~4,876 | Configuration file parsing / binding |
|
||||
| Auth | `server/auth.go` | 19 | ~1,296 | Authentication dispatch |
|
||||
| Auth | `server/auth_callout.go` | 3 | ~456 | External auth callout |
|
||||
| Auth | `server/jwt.go` | 3 | ~137 | Operator JWT validation |
|
||||
| Signals | `server/signal.go` | 1 | ~46 | OS signal handling |
|
||||
|
||||
---
|
||||
|
||||
## Session A: Configuration Binding (67 stubs, opts.go)
|
||||
|
||||
### Decision
|
||||
|
||||
Map all NATS server configuration to **`appsettings.json`** via
|
||||
`Microsoft.Extensions.Configuration`. The Go `conf` package tokenizer and the 765-line
|
||||
`processConfigFileLine` dispatch loop are **not ported** — JSON deserialization replaces them.
|
||||
|
||||
### New Files
|
||||
|
||||
**`Config/ServerOptionsConfiguration.cs`**
|
||||
|
||||
```csharp
|
||||
public static class ServerOptionsConfiguration
|
||||
{
|
||||
public static ServerOptions ProcessConfigFile(string path);
|
||||
public static ServerOptions ProcessConfigString(string json);
|
||||
public static void BindConfiguration(IConfiguration config, ServerOptions target);
|
||||
}
|
||||
```
|
||||
|
||||
- `ProcessConfigFile` uses `new ConfigurationBuilder().AddJsonFile(path).Build()`
|
||||
- `ProcessConfigString` uses `AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json)))`
|
||||
- `BindConfiguration` calls `config.Bind(target)` with custom converters registered
|
||||
|
||||
**`Config/NatsJsonConverters.cs`**
|
||||
|
||||
Custom `JsonConverter<T>` for non-trivial types:
|
||||
|
||||
| Converter | Input | Output | Mirrors |
|
||||
|-----------|-------|--------|---------|
|
||||
| `DurationJsonConverter` | `"2s"`, `"100ms"`, `"1h30m"` | `TimeSpan` | `parseDuration` |
|
||||
| `TlsVersionJsonConverter` | `"1.2"`, `"TLS12"` | `SslProtocols` | `parseTLSVersion` |
|
||||
| `NatsUrlJsonConverter` | `"nats://host:port"` | validated `string` | `parseURL` |
|
||||
| `StorageSizeJsonConverter` | `"1GB"`, `"512mb"` | `long` (bytes) | `getStorageSize` |
|
||||
|
||||
### ServerOptions.cs Changes
|
||||
|
||||
Add `[JsonPropertyName("...")]` attributes for fields whose JSON key names differ from C# names.
|
||||
JSON key names follow NATS server conventions (lowercase, underscore-separated):
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 4222,
|
||||
"host": "0.0.0.0",
|
||||
"tls": { "cert_file": "...", "key_file": "...", "ca_file": "..." },
|
||||
"cluster": { "port": 6222, "name": "my-cluster" },
|
||||
"gateway": { "port": 7222, "name": "my-gateway" },
|
||||
"jetstream": { "store_dir": "/data/jetstream", "max_memory": "1GB" },
|
||||
"leafnodes": { "port": 7422 },
|
||||
"mqtt": { "port": 1883 },
|
||||
"websocket": { "port": 8080 },
|
||||
"accounts": [ { "name": "A", "users": [ { "user": "u1", "password": "p1" } ] } ]
|
||||
}
|
||||
```
|
||||
|
||||
### DB Outcome
|
||||
|
||||
All 67 opts.go stubs → `complete`:
|
||||
- Feature IDs 2505–2574, 2580, 2584 (+ `configureSystemAccount` 2509, `setupUsersAndNKeysDuplicateCheckMap` 2515)
|
||||
- `parse*` functions have no C# equivalent — their logic is subsumed by converters and JSON binding
|
||||
|
||||
---
|
||||
|
||||
## Session B: Auth Implementation (26 stubs)
|
||||
|
||||
### New Files
|
||||
|
||||
**`NatsServer.Auth.cs`** — `partial class NatsServer` with:
|
||||
|
||||
| Method | Go Equivalent | Notes |
|
||||
|--------|--------------|-------|
|
||||
| `ConfigureAuthorization()` | `configureAuthorization` | Builds `_nkeys`/`_users` dicts from `_opts` |
|
||||
| `BuildNkeysAndUsersFromOptions()` | `buildNkeysAndUsersFromOptions` | Creates typed lookup maps |
|
||||
| `CheckAuthforWarnings()` | `checkAuthforWarnings` | Validates auth config consistency |
|
||||
| `AssignGlobalAccountToOrphanUsers()` | `assignGlobalAccountToOrphanUsers` | — |
|
||||
| `CheckAuthentication(ClientConnection)` | `checkAuthentication` | Entry point |
|
||||
| `IsClientAuthorized(ClientConnection)` | `isClientAuthorized` | Check user credentials |
|
||||
| `ProcessClientOrLeafAuthentication(ClientConnection, ServerOptions)` | `processClientOrLeafAuthentication` | Main 554-line auth dispatch |
|
||||
| `IsRouterAuthorized(ClientConnection)` | `isRouterAuthorized` | Route-specific auth |
|
||||
| `IsGatewayAuthorized(ClientConnection)` | `isGatewayAuthorized` | Gateway-specific auth |
|
||||
| `RegisterLeafWithAccount(ClientConnection, string)` | `registerLeafWithAccount` | — |
|
||||
| `IsLeafNodeAuthorized(ClientConnection)` | `isLeafNodeAuthorized` | Leaf-specific auth |
|
||||
| `ProcessProxiesTrustedKeys()` | `processProxiesTrustedKeys` | Proxy key setup |
|
||||
| `ProxyCheck(ClientConnection, ServerOptions)` | `proxyCheck` | Validate proxy headers |
|
||||
|
||||
**Auth dispatch flow in `ProcessClientOrLeafAuthentication`:**
|
||||
```
|
||||
if callout configured → ProcessClientOrLeafCallout()
|
||||
else if JWT bearer → JwtProcessor.ValidateAndRegisterUser()
|
||||
else if NKey → verify NKey signature (NATS.NKeys NuGet)
|
||||
else if user+password → BCrypt.Net.BCrypt.Verify() (BCrypt.Net-Next NuGet)
|
||||
else if TLS cert map → CheckClientTlsCertSubject()
|
||||
else if no-auth mode → allow (if opts.NoAuth)
|
||||
→ set client account, permissions, labels
|
||||
```
|
||||
|
||||
**`Auth/AuthCallout.cs`** — `partial class NatsServer` with:
|
||||
- `ProcessClientOrLeafCallout(ClientConnection, ServerOptions)` — publishes to `$SYS.REQ.USER.AUTH`, waits for signed JWT response, validates it
|
||||
- `FillClientInfo(AuthorizationRequestClaims, ClientConnection)` — populate auth request payload
|
||||
- `FillConnectOpts(AuthorizationRequestClaims, ClientConnection)` — populate connect opts in payload
|
||||
|
||||
**`Auth/JwtProcessor.cs` additions:**
|
||||
- `ReadOperatorJwt(string path)` — read operator JWT from file, decode `OperatorClaims`
|
||||
- `ReadOperatorJwtInternal(string jwtString)` — decode from string
|
||||
- `ValidateTrustedOperators(ServerOptions opts)` — walk operator → account → user signing key chain
|
||||
|
||||
**`Auth/AuthHandler.cs` additions:**
|
||||
- `ProcessUserPermissionsTemplate(UserPermissionLimits, UserClaims, Account)` — expand `{{account}}`, `{{tag.*}}` template variables in JWT user permissions
|
||||
- `GetTlsAuthDcs(X509DistinguishedName)` — extract DC= components from TLS cert RDN
|
||||
- `CheckClientTlsCertSubject(ClientConnection, Func<string, bool>)` — TLS cert subject matching
|
||||
- `ValidateProxies(ServerOptions)` — validate proxy configuration
|
||||
- `GetAuthErrClosedState(ClientConnection)` — map auth failure to client closed state enum
|
||||
|
||||
### New NuGet Packages
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `BCrypt.Net-Next` | ≥4.0 | bcrypt password hashing and comparison |
|
||||
| `NATS.NKeys` | ≥2.0 | NKey keypair creation, signature verify |
|
||||
|
||||
### `NatsServer.Signals.cs`
|
||||
|
||||
New partial class file:
|
||||
```csharp
|
||||
// Registers OS signal handlers via PosixSignalRegistration (cross-platform).
|
||||
// SIGHUP → server.Reload()
|
||||
// SIGTERM → server.Shutdown()
|
||||
// SIGINT → server.Shutdown()
|
||||
// Windows fallback: Console.CancelKeyPress → Shutdown()
|
||||
```
|
||||
|
||||
### DB Outcome
|
||||
|
||||
All 26 auth/jwt/callout/signal stubs → `complete`:
|
||||
- Feature IDs 354–383, 1973–1976, 2584, 3156
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `Config/ServerOptionsConfiguration.cs` | CREATE |
|
||||
| `Config/NatsJsonConverters.cs` | CREATE |
|
||||
| `NatsServer.Auth.cs` | CREATE (partial) |
|
||||
| `NatsServer.Signals.cs` | CREATE (partial) |
|
||||
| `Auth/AuthCallout.cs` | CREATE (partial) |
|
||||
| `Auth/JwtProcessor.cs` | MODIFY (add 3 methods) |
|
||||
| `Auth/AuthHandler.cs` | MODIFY (add 5 methods) |
|
||||
| `ServerOptions.cs` | MODIFY (add JsonPropertyName attrs) |
|
||||
| `ZB.MOM.NatsNet.Server.csproj` | MODIFY (add 2 NuGet packages) |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests in `ZB.MOM.NatsNet.Server.Tests/`:
|
||||
- `Config/ServerOptionsConfigurationTests.cs` — round-trip JSON bind tests for each major option group
|
||||
- `Auth/AuthHandlerTests.cs` additions — bcrypt comparison, NKey verify, TLS cert subject matching
|
||||
- `Auth/JwtProcessorTests.cs` additions — operator JWT read/validate
|
||||
|
||||
No new test IDs needed — these are implementations of already-tracked Phase 6 features.
|
||||
After implementation, relevant test IDs in Phase 7 will be marked complete.
|
||||
29
docs/plans/2026-02-27-agents-md-design.md
Normal file
29
docs/plans/2026-02-27-agents-md-design.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# AGENTS.md Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Create an `AGENTS.md` file at the project root for OpenAI Codex agents working on this codebase. The file provides project context, PortTracker CLI reference, porting workflow guidance, and pointers to .NET coding standards.
|
||||
|
||||
## Target
|
||||
|
||||
OpenAI Codex — follows Codex's AGENTS.md discovery conventions (root-level, markdown format, under 32KB).
|
||||
|
||||
## Structure Decision
|
||||
|
||||
**Flat single-file** at project root. The project information is tightly coupled — PortTracker commands are needed regardless of which directory Codex is editing. A single file keeps everything in context for every session.
|
||||
|
||||
## Sections
|
||||
|
||||
1. **Project Summary** — What the project is, where Go source and .NET code live
|
||||
2. **Folder Layout** — Directory tree with annotations
|
||||
3. **Build and Test** — Commands to build, run unit tests, run filtered tests, run integration tests
|
||||
4. **.NET Coding Standards** — Pointer to `docs/standards/dotnet-standards.md` with critical rules inlined (forbidden packages, naming, testing framework)
|
||||
5. **PortTracker CLI** — Full command reference: querying, updating, audit verification, valid statuses, batch syntax
|
||||
6. **Porting Workflow** — Step-by-step: finding work, implementing features, implementing tests, post-completion checklist
|
||||
7. **Go to .NET Translation Reference** — Quick-reference table for common Go-to-.NET pattern translations
|
||||
|
||||
## Size
|
||||
|
||||
~3.5KB — well within Codex's 32KB default limit.
|
||||
|
||||
<!-- Last verified against codebase: 2026-02-27 -->
|
||||
85
docs/plans/2026-02-27-audit-verified-updates-design.md
Normal file
85
docs/plans/2026-02-27-audit-verified-updates-design.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Audit-Verified Status Updates Design
|
||||
|
||||
## Goal
|
||||
|
||||
Require audit verification before applying status changes to features or unit tests. When the requested status disagrees with what the Roslyn audit determines, require an explicit override with a comment. Track all overrides in a new table for later review.
|
||||
|
||||
## Architecture
|
||||
|
||||
Inline audit verification: when `feature update`, `feature batch-update`, `test update`, or `test batch-update` runs, build the `SourceIndexer` on the fly, classify each item, and compare. If the requested status doesn't match the audit, block the update unless `--override "comment"` is provided.
|
||||
|
||||
## Override Table Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE status_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL CHECK (table_name IN ('features', 'unit_tests')),
|
||||
item_id INTEGER NOT NULL,
|
||||
audit_status TEXT NOT NULL,
|
||||
audit_reason TEXT NOT NULL,
|
||||
requested_status TEXT NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
Each row records: which table/item, what the audit said, what the user requested, and their justification.
|
||||
|
||||
## CLI Interface
|
||||
|
||||
### Single update
|
||||
|
||||
```bash
|
||||
# Audit agrees — applied directly
|
||||
dotnet run -- feature update 123 --status verified --db porting.db
|
||||
|
||||
# Audit disagrees — blocked
|
||||
# Error: "Audit classifies feature 123 as 'stub'. Use --override 'reason' to force."
|
||||
|
||||
# Override
|
||||
dotnet run -- feature update 123 --status verified --override "Manual review confirms complete" --db porting.db
|
||||
```
|
||||
|
||||
### Batch update
|
||||
|
||||
```bash
|
||||
# All items agree — applied
|
||||
dotnet run -- feature batch-update --module 5 --set-status verified --execute --db porting.db
|
||||
|
||||
# Some items disagree — blocked
|
||||
# "15 items match audit, 3 require override. Use --override 'reason' to force all."
|
||||
|
||||
# Override entire batch (one comment covers all mismatches)
|
||||
dotnet run -- feature batch-update --module 5 --set-status verified --override "Batch approved" --execute --db porting.db
|
||||
```
|
||||
|
||||
Same interface for `test update` and `test batch-update`.
|
||||
|
||||
## Verification Flow
|
||||
|
||||
1. Build `SourceIndexer` for the appropriate directory (features → `dotnet/src/...`, tests → `dotnet/tests/...`).
|
||||
2. For each item: query its `dotnet_class`, `dotnet_method`, `go_file`, `go_method` from DB. Run `FeatureClassifier.Classify()`.
|
||||
3. Compare requested status vs audit status. Collect mismatches.
|
||||
4. If mismatches and no `--override`: print details and exit with error.
|
||||
5. If `--override` provided: apply all updates. Insert one `status_overrides` row per mismatched item.
|
||||
6. Items that agree with audit: apply normally, no override row logged.
|
||||
|
||||
Items that cannot be audited (no dotnet_class/dotnet_method) are treated as mismatches requiring override.
|
||||
|
||||
## Override Review Command
|
||||
|
||||
```bash
|
||||
dotnet run -- override list --db porting.db
|
||||
dotnet run -- override list --type features --db porting.db
|
||||
```
|
||||
|
||||
Tabular output: id, table, item_id, audit_status, requested_status, comment, date.
|
||||
|
||||
## Changes Required
|
||||
|
||||
1. **porting-schema.sql**: Add `status_overrides` table.
|
||||
2. **FeatureCommands.cs**: Add `--override` option to `update` and `batch-update`. Integrate audit verification before applying.
|
||||
3. **TestCommands.cs**: Same changes as FeatureCommands.
|
||||
4. **New `OverrideCommands.cs`**: `override list` command.
|
||||
5. **Program.cs**: Wire `override` command group.
|
||||
6. **Shared helper**: Extract audit verification logic (build indexer, classify, compare) into a reusable method since both feature and test commands need it.
|
||||
154
docs/plans/2026-02-27-feature-audit-script-design.md
Normal file
154
docs/plans/2026-02-27-feature-audit-script-design.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Feature Audit Script Design
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
3394 features in module 8 (`server`) are marked `unknown`. The existing plan (`2026-02-27-feature-status-audit-plan.md`) describes a manual 68-batch process of inspecting .NET source and classifying each feature. This design automates that process.
|
||||
|
||||
## Solution
|
||||
|
||||
A new PortTracker CLI command `feature audit` that uses Roslyn syntax tree analysis to parse .NET source files, build a method index, and classify all unknown features automatically.
|
||||
|
||||
## Command Interface
|
||||
|
||||
```
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature audit \
|
||||
--source dotnet/src/ZB.MOM.NatsNet.Server/ \
|
||||
--output reports/audit-results.csv \
|
||||
--db porting.db \
|
||||
[--module 8] \
|
||||
[--execute]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--source` | `dotnet/src/ZB.MOM.NatsNet.Server/` | .NET source directory to parse |
|
||||
| `--output` | `reports/audit-results.csv` | CSV report output path |
|
||||
| `--db` | `porting.db` | SQLite database (inherited from root) |
|
||||
| `--module` | *(all)* | Restrict to a specific module ID |
|
||||
| `--execute` | `false` | Apply DB updates (default: dry-run) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component 1: Source Indexer (`Audit/SourceIndexer.cs`)
|
||||
|
||||
Parses all `.cs` files under the source directory into Roslyn syntax trees and builds a lookup index.
|
||||
|
||||
**Process:**
|
||||
1. Recursively glob `**/*.cs` (skip `obj/`, `bin/`)
|
||||
2. Parse each file with `CSharpSyntaxTree.ParseText()`
|
||||
3. Walk syntax trees for `ClassDeclarationSyntax` and `StructDeclarationSyntax`
|
||||
4. Extract all method, property, and constructor declarations
|
||||
5. Build dictionary: `Dictionary<(string className, string memberName), List<MethodInfo>>`
|
||||
|
||||
**`MethodInfo`:**
|
||||
- `FilePath` — source file path
|
||||
- `LineNumber` — starting line
|
||||
- `BodyLineCount` — lines in method body (excluding braces)
|
||||
- `IsStub` — body is `throw new NotImplementedException(...)` or empty
|
||||
- `IsPartial` — body has some logic AND a `NotImplementedException` throw
|
||||
- `StatementCount` — number of meaningful statements
|
||||
|
||||
**Partial class handling:** Same class name across multiple files produces multiple entries in the index. Lookup checks all of them — a feature is matched if the method exists in ANY file for that class.
|
||||
|
||||
**Name matching:** Case-insensitive comparison for both class and method names. Handles `dotnet_class` values that contain commas (e.g. `ClosedRingBuffer,ClosedClient`) by splitting and checking each.
|
||||
|
||||
### Component 2: Feature Classifier (`Audit/FeatureClassifier.cs`)
|
||||
|
||||
Classifies each feature using the source index. Priority order (first match wins):
|
||||
|
||||
**1. N/A Lookup Table**
|
||||
|
||||
Checked first against `(go_file, go_method)` or `dotnet_class` patterns:
|
||||
|
||||
| Pattern | Reason |
|
||||
|---------|--------|
|
||||
| Go logging functions (`Noticef`, `Debugf`, `Tracef`, `Warnf`, `Errorf`, `Fatalf`) | .NET uses Microsoft.Extensions.Logging |
|
||||
| Go signal handling (`HandleSignals`, `processSignal`) | .NET uses IHostApplicationLifetime |
|
||||
| Go HTTP handler setup (`Statz`, `Varz`, `Connz`, etc.) | .NET uses ASP.NET middleware |
|
||||
|
||||
Table is extensible — add entries as new patterns are identified.
|
||||
|
||||
**2. Method Not Found** -> `deferred`
|
||||
- `dotnet_class` not found in source index, OR
|
||||
- `dotnet_method` not found within the class
|
||||
|
||||
**3. Stub Detection** -> `stub`
|
||||
- Body is solely `throw new NotImplementedException(...)` (expression-bodied or block)
|
||||
- Body is empty (no statements)
|
||||
- Body has logic but also contains `NotImplementedException` (partial implementation)
|
||||
|
||||
**4. Verified** -> `verified`
|
||||
- Method exists with 1+ meaningful statements that are not `NotImplementedException` throws
|
||||
|
||||
### Component 3: Audit Command (`Commands/AuditCommand.cs`)
|
||||
|
||||
Orchestrates the audit:
|
||||
|
||||
1. Query `SELECT id, dotnet_class, dotnet_method, go_file, go_method FROM features WHERE status = 'unknown'` (optionally filtered by module)
|
||||
2. Build source index via `SourceIndexer`
|
||||
3. Classify each feature via `FeatureClassifier`
|
||||
4. Write CSV report
|
||||
5. Print console summary
|
||||
6. If `--execute`: update DB in a single transaction per status group
|
||||
|
||||
### DB Update Strategy
|
||||
|
||||
- Group features by `(new_status, notes)` tuple
|
||||
- One `UPDATE features SET status = @s, notes = @n WHERE id IN (...)` per group
|
||||
- All groups in a single transaction
|
||||
- For `n_a` features: set `notes` to the reason from the lookup table
|
||||
|
||||
## Output
|
||||
|
||||
### CSV Report
|
||||
|
||||
```csv
|
||||
id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason
|
||||
150,ServiceRespType,String,server/accounts.go,String,unknown,verified,Method found with 3 statements
|
||||
151,Account,NewAccount,server/accounts.go,NewAccount,unknown,stub,Body is throw NotImplementedException
|
||||
```
|
||||
|
||||
### Console Summary
|
||||
|
||||
```
|
||||
Feature Status Audit Results
|
||||
=============================
|
||||
Source: dotnet/src/ZB.MOM.NatsNet.Server/ (142 files, 4821 methods indexed)
|
||||
Features audited: 3394
|
||||
|
||||
verified: NNNN
|
||||
stub: NNNN
|
||||
n_a: NNNN
|
||||
deferred: NNNN
|
||||
|
||||
Dry-run mode. Add --execute to apply changes.
|
||||
Report: reports/audit-results.csv
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
New NuGet package required:
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
||||
```
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj` | Add Roslyn package reference |
|
||||
| `tools/NatsNet.PortTracker/Audit/SourceIndexer.cs` | New — Roslyn source parsing and indexing |
|
||||
| `tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs` | New — classification heuristics |
|
||||
| `tools/NatsNet.PortTracker/Commands/AuditCommand.cs` | New — CLI command wiring |
|
||||
| `tools/NatsNet.PortTracker/Program.cs` | Add `AuditCommand.Create()` to root command |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No semantic analysis (full compilation) — syntax trees are sufficient
|
||||
- No Go source parsing — we only inspect .NET source
|
||||
- No unit test reclassification — separate effort
|
||||
- No code changes to the server project — classification only
|
||||
813
docs/plans/2026-02-27-feature-audit-script-plan.md
Normal file
813
docs/plans/2026-02-27-feature-audit-script-plan.md
Normal file
@@ -0,0 +1,813 @@
|
||||
# Feature Audit Script Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a `feature audit` command to the PortTracker CLI that uses Roslyn syntax tree analysis to automatically classify 3394 unknown features into verified/stub/n_a/deferred.
|
||||
|
||||
**Architecture:** Three new files — `SourceIndexer` parses all .cs files and builds a method lookup index, `FeatureClassifier` applies classification heuristics, `AuditCommand` wires the CLI and orchestrates the audit. Direct DB updates via the existing `Database` class.
|
||||
|
||||
**Tech Stack:** `Microsoft.CodeAnalysis.CSharp` (Roslyn) for C# parsing, `Microsoft.Data.Sqlite` (existing), `System.CommandLine` (existing)
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-27-feature-audit-script-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Important Rules (Read Before Every Task)
|
||||
|
||||
1. All new files go under `tools/NatsNet.PortTracker/`
|
||||
2. Follow the existing code style — see `FeatureCommands.cs` and `BatchFilters.cs` for patterns
|
||||
3. Use `System.CommandLine` v3 (preview) APIs — `SetAction`, `parseResult.GetValue()`, etc.
|
||||
4. The `Database` class methods: `Query()`, `Execute()`, `ExecuteScalar<T>()`, `ExecuteInTransaction()`
|
||||
5. Run `dotnet build --project tools/NatsNet.PortTracker` after each file creation to verify compilation
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Add Roslyn NuGet package
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
|
||||
**Step 1: Add the package reference**
|
||||
|
||||
Add `Microsoft.CodeAnalysis.CSharp` to the csproj:
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
||||
```
|
||||
|
||||
The `<ItemGroup>` should look like:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
|
||||
<PackageReference Include="System.CommandLine" Version="3.0.0-preview.1.26104.118" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
**Step 2: Restore and build**
|
||||
|
||||
Run: `dotnet build --project tools/NatsNet.PortTracker`
|
||||
Expected: Build succeeded. 0 Error(s).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj
|
||||
git commit -m "chore: add Roslyn package to PortTracker for feature audit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create SourceIndexer — data model and file parsing
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/NatsNet.PortTracker/Audit/SourceIndexer.cs`
|
||||
|
||||
**Step 1: Create the SourceIndexer with MethodInfo record and indexing logic**
|
||||
|
||||
Create `tools/NatsNet.PortTracker/Audit/SourceIndexer.cs`:
|
||||
|
||||
```csharp
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace NatsNet.PortTracker.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Parses .cs files using Roslyn syntax trees and builds a lookup index
|
||||
/// of (className, memberName) -> list of MethodInfo.
|
||||
/// </summary>
|
||||
public sealed class SourceIndexer
|
||||
{
|
||||
public record MethodInfo(
|
||||
string FilePath,
|
||||
int LineNumber,
|
||||
int BodyLineCount,
|
||||
bool IsStub,
|
||||
bool IsPartial,
|
||||
int StatementCount);
|
||||
|
||||
// Key: (className lowercase, memberName lowercase)
|
||||
private readonly Dictionary<(string, string), List<MethodInfo>> _index = new();
|
||||
|
||||
public int FilesIndexed { get; private set; }
|
||||
public int MethodsIndexed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Recursively parses all .cs files under <paramref name="sourceDir"/>
|
||||
/// (skipping obj/ and bin/) and populates the index.
|
||||
/// </summary>
|
||||
public void IndexDirectory(string sourceDir)
|
||||
{
|
||||
var files = Directory.EnumerateFiles(sourceDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
{
|
||||
var rel = Path.GetRelativePath(sourceDir, f);
|
||||
return !rel.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
&& !rel.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
|
||||
&& !rel.StartsWith($"obj{Path.DirectorySeparatorChar}")
|
||||
&& !rel.StartsWith($"bin{Path.DirectorySeparatorChar}");
|
||||
});
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
IndexFile(file);
|
||||
FilesIndexed++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up all method declarations for a given class and member name.
|
||||
/// Case-insensitive. Returns empty list if not found.
|
||||
/// </summary>
|
||||
public List<MethodInfo> Lookup(string className, string memberName)
|
||||
{
|
||||
var key = (className.ToLowerInvariant(), memberName.ToLowerInvariant());
|
||||
return _index.TryGetValue(key, out var list) ? list : [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the class exists anywhere in the index (any member).
|
||||
/// </summary>
|
||||
public bool HasClass(string className)
|
||||
{
|
||||
var lower = className.ToLowerInvariant();
|
||||
return _index.Keys.Any(k => k.Item1 == lower);
|
||||
}
|
||||
|
||||
private void IndexFile(string filePath)
|
||||
{
|
||||
var source = File.ReadAllText(filePath);
|
||||
var tree = CSharpSyntaxTree.ParseText(source, path: filePath);
|
||||
var root = tree.GetCompilationUnitRoot();
|
||||
|
||||
foreach (var typeDecl in root.DescendantNodes().OfType<TypeDeclarationSyntax>())
|
||||
{
|
||||
var className = typeDecl.Identifier.Text.ToLowerInvariant();
|
||||
|
||||
// Methods
|
||||
foreach (var method in typeDecl.Members.OfType<MethodDeclarationSyntax>())
|
||||
{
|
||||
var info = AnalyzeMethod(filePath, method.Body, method.ExpressionBody, method.GetLocation());
|
||||
AddToIndex(className, method.Identifier.Text.ToLowerInvariant(), info);
|
||||
}
|
||||
|
||||
// Properties (get/set are like methods)
|
||||
foreach (var prop in typeDecl.Members.OfType<PropertyDeclarationSyntax>())
|
||||
{
|
||||
var info = AnalyzeProperty(filePath, prop);
|
||||
AddToIndex(className, prop.Identifier.Text.ToLowerInvariant(), info);
|
||||
}
|
||||
|
||||
// Constructors — index as class name
|
||||
foreach (var ctor in typeDecl.Members.OfType<ConstructorDeclarationSyntax>())
|
||||
{
|
||||
var info = AnalyzeMethod(filePath, ctor.Body, ctor.ExpressionBody, ctor.GetLocation());
|
||||
AddToIndex(className, ctor.Identifier.Text.ToLowerInvariant(), info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private MethodInfo AnalyzeMethod(string filePath, BlockSyntax? body, ArrowExpressionClauseSyntax? expressionBody, Location location)
|
||||
{
|
||||
var lineSpan = location.GetLineSpan();
|
||||
var lineNumber = lineSpan.StartLinePosition.Line + 1;
|
||||
|
||||
if (expressionBody is not null)
|
||||
{
|
||||
// Expression-bodied: => expr;
|
||||
var isStub = IsNotImplementedExpression(expressionBody.Expression);
|
||||
return new MethodInfo(filePath, lineNumber, 1, IsStub: isStub, IsPartial: false, StatementCount: isStub ? 0 : 1);
|
||||
}
|
||||
|
||||
if (body is null || body.Statements.Count == 0)
|
||||
{
|
||||
// No body or empty body
|
||||
return new MethodInfo(filePath, lineNumber, 0, IsStub: true, IsPartial: false, StatementCount: 0);
|
||||
}
|
||||
|
||||
var bodyLines = body.GetLocation().GetLineSpan();
|
||||
var bodyLineCount = bodyLines.EndLinePosition.Line - bodyLines.StartLinePosition.Line - 1; // exclude braces
|
||||
|
||||
var statements = body.Statements;
|
||||
var hasNotImplemented = statements.Any(s => IsNotImplementedStatement(s));
|
||||
var meaningfulCount = statements.Count(s => !IsNotImplementedStatement(s));
|
||||
|
||||
// Pure stub: single throw NotImplementedException
|
||||
if (statements.Count == 1 && hasNotImplemented)
|
||||
return new MethodInfo(filePath, lineNumber, bodyLineCount, IsStub: true, IsPartial: false, StatementCount: 0);
|
||||
|
||||
// Partial: has some logic AND a NotImplementedException
|
||||
if (hasNotImplemented && meaningfulCount > 0)
|
||||
return new MethodInfo(filePath, lineNumber, bodyLineCount, IsStub: false, IsPartial: true, StatementCount: meaningfulCount);
|
||||
|
||||
// Real logic
|
||||
return new MethodInfo(filePath, lineNumber, bodyLineCount, IsStub: false, IsPartial: false, StatementCount: meaningfulCount);
|
||||
}
|
||||
|
||||
private MethodInfo AnalyzeProperty(string filePath, PropertyDeclarationSyntax prop)
|
||||
{
|
||||
var lineSpan = prop.GetLocation().GetLineSpan();
|
||||
var lineNumber = lineSpan.StartLinePosition.Line + 1;
|
||||
|
||||
// Expression-bodied property: int Foo => expr;
|
||||
if (prop.ExpressionBody is not null)
|
||||
{
|
||||
var isStub = IsNotImplementedExpression(prop.ExpressionBody.Expression);
|
||||
return new MethodInfo(filePath, lineNumber, 1, IsStub: isStub, IsPartial: false, StatementCount: isStub ? 0 : 1);
|
||||
}
|
||||
|
||||
// Auto-property: int Foo { get; set; } — this is valid, not a stub
|
||||
if (prop.AccessorList is not null && prop.AccessorList.Accessors.All(a => a.Body is null && a.ExpressionBody is null))
|
||||
return new MethodInfo(filePath, lineNumber, 0, IsStub: false, IsPartial: false, StatementCount: 1);
|
||||
|
||||
// Property with accessor bodies — check if any are stubs
|
||||
if (prop.AccessorList is not null)
|
||||
{
|
||||
var hasStub = prop.AccessorList.Accessors.Any(a =>
|
||||
(a.ExpressionBody is not null && IsNotImplementedExpression(a.ExpressionBody.Expression)) ||
|
||||
(a.Body is not null && a.Body.Statements.Count == 1 && IsNotImplementedStatement(a.Body.Statements[0])));
|
||||
return new MethodInfo(filePath, lineNumber, 0, IsStub: hasStub, IsPartial: false, StatementCount: hasStub ? 0 : 1);
|
||||
}
|
||||
|
||||
return new MethodInfo(filePath, lineNumber, 0, IsStub: false, IsPartial: false, StatementCount: 1);
|
||||
}
|
||||
|
||||
private static bool IsNotImplementedExpression(ExpressionSyntax expr)
|
||||
{
|
||||
// throw new NotImplementedException(...)
|
||||
if (expr is ThrowExpressionSyntax throwExpr)
|
||||
return throwExpr.Expression is ObjectCreationExpressionSyntax oc
|
||||
&& oc.Type.ToString().Contains("NotImplementedException");
|
||||
// new NotImplementedException() — shouldn't normally be standalone but handle it
|
||||
return expr is ObjectCreationExpressionSyntax oc2
|
||||
&& oc2.Type.ToString().Contains("NotImplementedException");
|
||||
}
|
||||
|
||||
private static bool IsNotImplementedStatement(StatementSyntax stmt)
|
||||
{
|
||||
// throw new NotImplementedException(...);
|
||||
if (stmt is ThrowStatementSyntax throwStmt && throwStmt.Expression is not null)
|
||||
return throwStmt.Expression is ObjectCreationExpressionSyntax oc
|
||||
&& oc.Type.ToString().Contains("NotImplementedException");
|
||||
// Expression statement containing throw expression
|
||||
if (stmt is ExpressionStatementSyntax exprStmt)
|
||||
return IsNotImplementedExpression(exprStmt.Expression);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void AddToIndex(string className, string memberName, MethodInfo info)
|
||||
{
|
||||
var key = (className, memberName);
|
||||
if (!_index.TryGetValue(key, out var list))
|
||||
{
|
||||
list = [];
|
||||
_index[key] = list;
|
||||
}
|
||||
list.Add(info);
|
||||
MethodsIndexed++;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build --project tools/NatsNet.PortTracker`
|
||||
Expected: Build succeeded. 0 Error(s).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Audit/SourceIndexer.cs
|
||||
git commit -m "feat: add SourceIndexer — Roslyn-based .NET source parser for audit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create FeatureClassifier — classification heuristics
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs`
|
||||
|
||||
**Step 1: Create the FeatureClassifier with n_a lookup and heuristics**
|
||||
|
||||
Create `tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs`:
|
||||
|
||||
```csharp
|
||||
namespace NatsNet.PortTracker.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies features by inspecting the SourceIndexer for their .NET implementation status.
|
||||
/// Priority: n_a lookup → method-not-found → stub detection → verified.
|
||||
/// </summary>
|
||||
public sealed class FeatureClassifier
|
||||
{
|
||||
public record ClassificationResult(string Status, string Reason);
|
||||
|
||||
public record FeatureRecord(
|
||||
long Id,
|
||||
string DotnetClass,
|
||||
string DotnetMethod,
|
||||
string GoFile,
|
||||
string GoMethod);
|
||||
|
||||
private readonly SourceIndexer _indexer;
|
||||
|
||||
// N/A lookup: (goMethod pattern) -> reason
|
||||
// Checked case-insensitively against go_method
|
||||
private static readonly Dictionary<string, string> NaByGoMethod = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Noticef"] = ".NET uses Microsoft.Extensions.Logging",
|
||||
["Debugf"] = ".NET uses Microsoft.Extensions.Logging",
|
||||
["Tracef"] = ".NET uses Microsoft.Extensions.Logging",
|
||||
["Warnf"] = ".NET uses Microsoft.Extensions.Logging",
|
||||
["Errorf"] = ".NET uses Microsoft.Extensions.Logging",
|
||||
["Fatalf"] = ".NET uses Microsoft.Extensions.Logging",
|
||||
};
|
||||
|
||||
// N/A lookup: go_file + go_method patterns
|
||||
private static readonly List<(Func<FeatureRecord, bool> Match, string Reason)> NaPatterns =
|
||||
[
|
||||
// Signal handling — .NET uses IHostApplicationLifetime
|
||||
(f => f.GoMethod.Equals("handleSignals", StringComparison.OrdinalIgnoreCase), ".NET uses IHostApplicationLifetime"),
|
||||
(f => f.GoMethod.Equals("processSignal", StringComparison.OrdinalIgnoreCase), ".NET uses IHostApplicationLifetime"),
|
||||
];
|
||||
|
||||
public FeatureClassifier(SourceIndexer indexer)
|
||||
{
|
||||
_indexer = indexer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a single feature. Returns status and reason.
|
||||
/// </summary>
|
||||
public ClassificationResult Classify(FeatureRecord feature)
|
||||
{
|
||||
// 1. N/A lookup — check go_method against known patterns
|
||||
if (NaByGoMethod.TryGetValue(feature.GoMethod, out var naReason))
|
||||
return new ClassificationResult("n_a", naReason);
|
||||
|
||||
foreach (var (match, reason) in NaPatterns)
|
||||
{
|
||||
if (match(feature))
|
||||
return new ClassificationResult("n_a", reason);
|
||||
}
|
||||
|
||||
// 2. Handle comma-separated dotnet_class (e.g. "ClosedRingBuffer,ClosedClient")
|
||||
var classNames = feature.DotnetClass.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var methodName = feature.DotnetMethod;
|
||||
|
||||
// Try each class name
|
||||
foreach (var className in classNames)
|
||||
{
|
||||
var methods = _indexer.Lookup(className, methodName);
|
||||
if (methods.Count > 0)
|
||||
{
|
||||
// Found the method — classify based on body analysis
|
||||
// Use the "best" match: prefer non-stub over stub
|
||||
var best = methods.OrderByDescending(m => m.StatementCount).First();
|
||||
|
||||
if (best.IsStub)
|
||||
return new ClassificationResult("stub", $"Body is throw NotImplementedException at {Path.GetFileName(best.FilePath)}:{best.LineNumber}");
|
||||
|
||||
if (best.IsPartial)
|
||||
return new ClassificationResult("stub", $"Partial implementation with NotImplementedException at {Path.GetFileName(best.FilePath)}:{best.LineNumber}");
|
||||
|
||||
return new ClassificationResult("verified", $"Method found with {best.StatementCount} statement(s) at {Path.GetFileName(best.FilePath)}:{best.LineNumber}");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Method not found — check if any class exists
|
||||
var anyClassFound = classNames.Any(c => _indexer.HasClass(c));
|
||||
if (anyClassFound)
|
||||
return new ClassificationResult("deferred", "Class exists but method not found");
|
||||
|
||||
return new ClassificationResult("deferred", "Class not found in .NET source");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build --project tools/NatsNet.PortTracker`
|
||||
Expected: Build succeeded. 0 Error(s).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs
|
||||
git commit -m "feat: add FeatureClassifier — heuristic-based feature classification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create AuditCommand — CLI wiring and orchestration
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/NatsNet.PortTracker/Commands/AuditCommand.cs`
|
||||
- Modify: `tools/NatsNet.PortTracker/Program.cs:36` — add `AuditCommand` to root command
|
||||
|
||||
**Step 1: Create the AuditCommand**
|
||||
|
||||
Create `tools/NatsNet.PortTracker/Commands/AuditCommand.cs`:
|
||||
|
||||
```csharp
|
||||
using System.CommandLine;
|
||||
using System.Text;
|
||||
using NatsNet.PortTracker.Audit;
|
||||
using NatsNet.PortTracker.Data;
|
||||
|
||||
namespace NatsNet.PortTracker.Commands;
|
||||
|
||||
public static class AuditCommand
|
||||
{
|
||||
public static Command Create(Option<string> dbOption)
|
||||
{
|
||||
var sourceOpt = new Option<string>("--source")
|
||||
{
|
||||
Description = "Path to the .NET source directory",
|
||||
DefaultValueFactory = _ => Path.Combine(Directory.GetCurrentDirectory(), "dotnet", "src", "ZB.MOM.NatsNet.Server")
|
||||
};
|
||||
|
||||
var outputOpt = new Option<string>("--output")
|
||||
{
|
||||
Description = "CSV report output path",
|
||||
DefaultValueFactory = _ => Path.Combine(Directory.GetCurrentDirectory(), "reports", "audit-results.csv")
|
||||
};
|
||||
|
||||
var moduleOpt = new Option<int?>("--module")
|
||||
{
|
||||
Description = "Restrict to a specific module ID"
|
||||
};
|
||||
|
||||
var executeOpt = new Option<bool>("--execute")
|
||||
{
|
||||
Description = "Apply DB updates (default: dry-run preview)",
|
||||
DefaultValueFactory = _ => false
|
||||
};
|
||||
|
||||
var cmd = new Command("audit", "Classify unknown features by inspecting .NET source code");
|
||||
cmd.Add(sourceOpt);
|
||||
cmd.Add(outputOpt);
|
||||
cmd.Add(moduleOpt);
|
||||
cmd.Add(executeOpt);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var sourcePath = parseResult.GetValue(sourceOpt)!;
|
||||
var outputPath = parseResult.GetValue(outputOpt)!;
|
||||
var moduleId = parseResult.GetValue(moduleOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
|
||||
RunAudit(dbPath, sourcePath, outputPath, moduleId, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static void RunAudit(string dbPath, string sourcePath, string outputPath, int? moduleId, bool execute)
|
||||
{
|
||||
// Validate source directory
|
||||
if (!Directory.Exists(sourcePath))
|
||||
{
|
||||
Console.WriteLine($"Error: source directory not found: {sourcePath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Build source index
|
||||
Console.WriteLine($"Parsing .NET source files in {sourcePath}...");
|
||||
var indexer = new SourceIndexer();
|
||||
indexer.IndexDirectory(sourcePath);
|
||||
Console.WriteLine($"Indexed {indexer.FilesIndexed} files, {indexer.MethodsIndexed} methods/properties.");
|
||||
|
||||
// 2. Query unknown features
|
||||
using var db = new Database(dbPath);
|
||||
var sql = "SELECT id, dotnet_class, dotnet_method, go_file, go_method FROM features WHERE status = 'unknown'";
|
||||
var parameters = new List<(string, object?)>();
|
||||
if (moduleId is not null)
|
||||
{
|
||||
sql += " AND module_id = @module";
|
||||
parameters.Add(("@module", moduleId));
|
||||
}
|
||||
sql += " ORDER BY id";
|
||||
|
||||
var rows = db.Query(sql, parameters.ToArray());
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No unknown features found.");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"Found {rows.Count} unknown features to classify.\n");
|
||||
|
||||
// 3. Classify each feature
|
||||
var classifier = new FeatureClassifier(indexer);
|
||||
var results = new List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)>();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var feature = new FeatureClassifier.FeatureRecord(
|
||||
Id: Convert.ToInt64(row["id"]),
|
||||
DotnetClass: row["dotnet_class"]?.ToString() ?? "",
|
||||
DotnetMethod: row["dotnet_method"]?.ToString() ?? "",
|
||||
GoFile: row["go_file"]?.ToString() ?? "",
|
||||
GoMethod: row["go_method"]?.ToString() ?? "");
|
||||
|
||||
var result = classifier.Classify(feature);
|
||||
results.Add((feature, result));
|
||||
}
|
||||
|
||||
// 4. Write CSV report
|
||||
WriteCsvReport(outputPath, results);
|
||||
|
||||
// 5. Print console summary
|
||||
var grouped = results.GroupBy(r => r.Result.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
Console.WriteLine("Feature Status Audit Results");
|
||||
Console.WriteLine("=============================");
|
||||
Console.WriteLine($"Source: {sourcePath} ({indexer.FilesIndexed} files, {indexer.MethodsIndexed} methods indexed)");
|
||||
Console.WriteLine($"Features audited: {results.Count}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($" verified: {grouped.GetValueOrDefault("verified", 0)}");
|
||||
Console.WriteLine($" stub: {grouped.GetValueOrDefault("stub", 0)}");
|
||||
Console.WriteLine($" n_a: {grouped.GetValueOrDefault("n_a", 0)}");
|
||||
Console.WriteLine($" deferred: {grouped.GetValueOrDefault("deferred", 0)}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (!execute)
|
||||
{
|
||||
Console.WriteLine("Dry-run mode. Add --execute to apply changes.");
|
||||
Console.WriteLine($"Report: {outputPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Apply DB updates
|
||||
ApplyUpdates(db, results);
|
||||
Console.WriteLine($"Report: {outputPath}");
|
||||
}
|
||||
|
||||
private static void WriteCsvReport(
|
||||
string outputPath,
|
||||
List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)> results)
|
||||
{
|
||||
// Ensure directory exists
|
||||
var dir = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason");
|
||||
foreach (var (feature, result) in results)
|
||||
{
|
||||
sb.AppendLine($"{feature.Id},{CsvEscape(feature.DotnetClass)},{CsvEscape(feature.DotnetMethod)},{CsvEscape(feature.GoFile)},{CsvEscape(feature.GoMethod)},unknown,{result.Status},{CsvEscape(result.Reason)}");
|
||||
}
|
||||
File.WriteAllText(outputPath, sb.ToString());
|
||||
}
|
||||
|
||||
private static void ApplyUpdates(
|
||||
Database db,
|
||||
List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)> results)
|
||||
{
|
||||
// Group by (status, notes) for efficient batch updates
|
||||
var groups = results
|
||||
.GroupBy(r => (r.Result.Status, Notes: r.Result.Status == "n_a" ? r.Result.Reason : (string?)null))
|
||||
.ToList();
|
||||
|
||||
var totalUpdated = 0;
|
||||
using var transaction = db.Connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var ids = group.Select(r => r.Feature.Id).ToList();
|
||||
var status = group.Key.Status;
|
||||
var notes = group.Key.Notes;
|
||||
|
||||
// Build parameterized IN clause
|
||||
var placeholders = new List<string>();
|
||||
using var cmd = db.CreateCommand("");
|
||||
for (var i = 0; i < ids.Count; i++)
|
||||
{
|
||||
placeholders.Add($"@id{i}");
|
||||
cmd.Parameters.AddWithValue($"@id{i}", ids[i]);
|
||||
}
|
||||
|
||||
cmd.Parameters.AddWithValue("@status", status);
|
||||
|
||||
if (notes is not null)
|
||||
{
|
||||
cmd.CommandText = $"UPDATE features SET status = @status, notes = @notes WHERE id IN ({string.Join(", ", placeholders)})";
|
||||
cmd.Parameters.AddWithValue("@notes", notes);
|
||||
}
|
||||
else
|
||||
{
|
||||
cmd.CommandText = $"UPDATE features SET status = @status WHERE id IN ({string.Join(", ", placeholders)})";
|
||||
}
|
||||
|
||||
cmd.Transaction = transaction;
|
||||
var affected = cmd.ExecuteNonQuery();
|
||||
totalUpdated += affected;
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
Console.WriteLine($"Updated {totalUpdated} features.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
Console.WriteLine("Error: transaction rolled back.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string CsvEscape(string value)
|
||||
{
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
return value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Wire the command into Program.cs**
|
||||
|
||||
In `tools/NatsNet.PortTracker/Program.cs`, add after the existing command registrations (after line 41, before `var parseResult`):
|
||||
|
||||
Find this line:
|
||||
```csharp
|
||||
rootCommand.Add(PhaseCommands.Create(dbOption, schemaOption));
|
||||
```
|
||||
|
||||
Add immediately after it:
|
||||
```csharp
|
||||
rootCommand.Add(AuditCommand.Create(dbOption));
|
||||
```
|
||||
|
||||
Also add the import — but since the file uses top-level statements and already imports `NatsNet.PortTracker.Commands`, no new using is needed (AuditCommand is in the same namespace).
|
||||
|
||||
**Step 3: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build --project tools/NatsNet.PortTracker`
|
||||
Expected: Build succeeded. 0 Error(s).
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Commands/AuditCommand.cs tools/NatsNet.PortTracker/Program.cs
|
||||
git commit -m "feat: add audit command — orchestrates feature status classification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Smoke test — dry-run on the real database
|
||||
|
||||
**Files:** None — testing only.
|
||||
|
||||
**Step 1: Run the audit in dry-run mode**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- audit --source dotnet/src/ZB.MOM.NatsNet.Server/ --db porting.db --output reports/audit-results.csv
|
||||
```
|
||||
|
||||
Expected output similar to:
|
||||
```
|
||||
Parsing .NET source files in dotnet/src/ZB.MOM.NatsNet.Server/...
|
||||
Indexed ~92 files, ~NNNN methods/properties.
|
||||
Found 3394 unknown features to classify.
|
||||
|
||||
Feature Status Audit Results
|
||||
=============================
|
||||
Source: dotnet/src/ZB.MOM.NatsNet.Server/ (92 files, NNNN methods indexed)
|
||||
Features audited: 3394
|
||||
|
||||
verified: NNNN
|
||||
stub: NNNN
|
||||
n_a: NNNN
|
||||
deferred: NNNN
|
||||
|
||||
Dry-run mode. Add --execute to apply changes.
|
||||
Report: reports/audit-results.csv
|
||||
```
|
||||
|
||||
**Step 2: Inspect the CSV report**
|
||||
|
||||
```bash
|
||||
head -20 reports/audit-results.csv
|
||||
```
|
||||
|
||||
Verify:
|
||||
- Header row matches: `id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason`
|
||||
- Each row has a classification and reason
|
||||
- The known n_a features (Noticef, Debugf etc.) show as `n_a`
|
||||
|
||||
**Step 3: Spot-check a few classifications**
|
||||
|
||||
Pick 3-5 features from the CSV and manually verify:
|
||||
- A `verified` feature: check the .NET method has real logic
|
||||
- A `stub` feature: check the .NET method is `throw new NotImplementedException`
|
||||
- A `deferred` feature: check the class/method doesn't exist
|
||||
- An `n_a` feature: check it's a Go logging function
|
||||
|
||||
If any classifications are wrong, fix the heuristics before proceeding.
|
||||
|
||||
**Step 4: Check the counts add up**
|
||||
|
||||
```bash
|
||||
wc -l reports/audit-results.csv
|
||||
```
|
||||
|
||||
Expected: 3395 lines (3394 data rows + 1 header).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Execute the audit and update the database
|
||||
|
||||
**Files:** None — execution only.
|
||||
|
||||
**Step 1: Back up the database**
|
||||
|
||||
```bash
|
||||
cp porting.db porting.db.pre-audit-backup
|
||||
```
|
||||
|
||||
**Step 2: Run with --execute**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- audit --source dotnet/src/ZB.MOM.NatsNet.Server/ --db porting.db --output reports/audit-results.csv --execute
|
||||
```
|
||||
|
||||
Expected: `Updated 3394 features.`
|
||||
|
||||
**Step 3: Verify zero unknown features remain**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Expected: `Total: 0 features`
|
||||
|
||||
**Step 4: Verify status breakdown**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
|
||||
```
|
||||
|
||||
Review the numbers match the dry-run output.
|
||||
|
||||
**Step 5: Generate updated porting report**
|
||||
|
||||
```bash
|
||||
./reports/generate-report.sh
|
||||
```
|
||||
|
||||
**Step 6: Commit everything**
|
||||
|
||||
```bash
|
||||
git add porting.db reports/ tools/NatsNet.PortTracker/
|
||||
git commit -m "feat: run feature status audit — classify 3394 unknown features
|
||||
|
||||
Automated classification using Roslyn syntax tree analysis:
|
||||
verified: NNNN (update with actual numbers)
|
||||
stub: NNNN
|
||||
n_a: NNNN
|
||||
deferred: NNNN"
|
||||
```
|
||||
|
||||
(Update the commit message with the actual numbers from the output.)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Cleanup — remove backup
|
||||
|
||||
**Files:** None.
|
||||
|
||||
**Step 1: Verify everything is committed and the database is correct**
|
||||
|
||||
```bash
|
||||
git status
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Expected: clean working tree, 0 unknown features.
|
||||
|
||||
**Step 2: Remove the pre-audit backup**
|
||||
|
||||
```bash
|
||||
rm porting.db.pre-audit-backup
|
||||
```
|
||||
|
||||
**Step 3: Final summary**
|
||||
|
||||
Print:
|
||||
```
|
||||
Feature Status Audit Complete
|
||||
=============================
|
||||
Total features audited: 3394
|
||||
verified: NNNN
|
||||
stub: NNNN
|
||||
n_a: NNNN
|
||||
deferred: NNNN
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-27-feature-audit-script-plan.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Add Roslyn NuGet package", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Create SourceIndexer", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: Create FeatureClassifier", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Create AuditCommand + wire CLI", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: Smoke test dry-run", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Execute audit and update DB", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: Cleanup and final verification", "status": "pending", "blockedBy": [5]}
|
||||
],
|
||||
"lastUpdated": "2026-02-27T00:00:00Z"
|
||||
}
|
||||
106
docs/plans/2026-02-27-feature-status-audit-design.md
Normal file
106
docs/plans/2026-02-27-feature-status-audit-design.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Feature Status Audit Design
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
3394 features in module 8 (`server`) are marked as `unknown` status after a bulk reclassification. Each needs to be checked against its .NET implementation to determine the correct status.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Module:** 8 (server) — all 3394 unknown features
|
||||
- **Go source files:** 64 distinct files
|
||||
- **All features have `dotnet_class` and `dotnet_method` mappings** — no unmapped features
|
||||
|
||||
## Classification Criteria
|
||||
|
||||
| Status | Criteria | Example |
|
||||
|--------|----------|---------|
|
||||
| `verified` | .NET method exists with non-trivial logic matching Go behavior | `MemStore.StoreRawMsg` — full implementation |
|
||||
| `stub` | .NET method exists but is `throw new NotImplementedException()`, empty, or only partially implemented | `FileStore.Compact` — no real logic |
|
||||
| `n_a` | Go feature doesn't apply to .NET — .NET uses a different approach (different library, runtime feature, or platform pattern) | Go logging functions → .NET uses `Microsoft.Extensions.Logging` |
|
||||
| `deferred` | .NET method doesn't exist, or classification requires running the server end-to-end | Server-integration features needing full runtime |
|
||||
|
||||
**Partial implementations** (method exists with some logic but missing significant functionality) are classified as `stub`.
|
||||
|
||||
## Batch Execution Process
|
||||
|
||||
Features are processed in fixed batches of 50. Each batch follows this workflow:
|
||||
|
||||
### Step 1: Fetch next 50 unknown features
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Take the first 50 IDs from the output.
|
||||
|
||||
### Step 2: Inspect .NET source for each feature
|
||||
|
||||
For each feature:
|
||||
1. Read the `dotnet_class` and `dotnet_method` from the feature record
|
||||
2. Find the .NET source file containing that class
|
||||
3. Check the method body:
|
||||
- Real logic matching Go = `verified`
|
||||
- Stub / empty / partial = `stub`
|
||||
- .NET alternative exists = `n_a`
|
||||
- Method not found = `deferred`
|
||||
|
||||
### Step 3: Dry-run the batch update (MANDATORY)
|
||||
|
||||
Group features by their determined status and dry-run using PortTracker:
|
||||
|
||||
```bash
|
||||
# Dry-run — verify correct features affected
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 150-160 --set-status deferred --db porting.db
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 2068-2077 --set-status verified --db porting.db
|
||||
```
|
||||
|
||||
Review the preview output. Only proceed if the listed features match expectations.
|
||||
|
||||
### Step 4: Execute once dry-run verified
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 150-160 --set-status deferred --execute --db porting.db
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 2068-2077 --set-status verified --execute --db porting.db
|
||||
```
|
||||
|
||||
### Step 5: Verify remaining count
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Confirm the count decreased by ~50.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **ALWAYS dry-run before executing** — no exceptions
|
||||
2. **NEVER use direct SQL** (`sqlite3`) — use PortTracker CLI exclusively
|
||||
3. **Process exactly 50 per batch** (or fewer if fewer remain)
|
||||
4. **Report classification breakdown** after each batch (e.g. "Batch 3: 12 verified, 30 stub, 3 n_a, 5 deferred")
|
||||
5. **68 batches total** (3394 / 50 = ~68)
|
||||
|
||||
## Key .NET Source Locations
|
||||
|
||||
```
|
||||
dotnet/src/ZB.MOM.NatsNet.Server/
|
||||
Accounts/Account.cs, AccountResolver.cs, DirJwtStore.cs
|
||||
Auth/AuthHandler.cs, JwtProcessor.cs
|
||||
Config/ReloadOptions.cs, ServerOptionsConfiguration.cs
|
||||
JetStream/MemStore.cs, FileStore.cs, JetStreamTypes.cs
|
||||
JetStream/NatsStream.cs, NatsConsumer.cs, RaftTypes.cs
|
||||
Protocol/ProtocolParser.cs, ProxyProtocol.cs
|
||||
Routes/RouteTypes.cs, LeafNode/LeafNodeTypes.cs, Gateway/GatewayTypes.cs
|
||||
Mqtt/MqttHandler.cs, WebSocket/WebSocketTypes.cs
|
||||
Internal/ (various data structures)
|
||||
NatsServer.cs, NatsServer.*.cs (partial class files)
|
||||
ClientConnection.cs
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No code changes — this is classification only
|
||||
- No unit_tests reclassification (separate effort)
|
||||
- No schema changes needed (`unknown` already added)
|
||||
236
docs/plans/2026-02-27-feature-status-audit-plan.md
Normal file
236
docs/plans/2026-02-27-feature-status-audit-plan.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Feature Status Audit Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Classify 3394 features currently marked `unknown` into the correct status (`verified`, `stub`, `n_a`, or `deferred`) by inspecting .NET source code against Go feature specifications.
|
||||
|
||||
**Architecture:** Process features in sequential batches of 50. Each batch: fetch 50 unknown features via PortTracker CLI, inspect the corresponding .NET source files, classify each feature, dry-run the batch updates, then execute. Repeat until zero unknown features remain.
|
||||
|
||||
**Tech Stack:** PortTracker CLI (`dotnet run --project tools/NatsNet.PortTracker`), .NET source at `dotnet/src/ZB.MOM.NatsNet.Server/`
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-27-feature-status-audit-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Important Rules (Read Before Every Task)
|
||||
|
||||
1. **ALWAYS dry-run before executing** — no exceptions. Every `batch-update` command must be run WITHOUT `--execute` first to preview.
|
||||
2. **NEVER use direct SQL** (`sqlite3`) — use the PortTracker CLI exclusively for all database operations.
|
||||
3. **Process exactly 50 per batch** (or fewer if fewer remain in the final batch).
|
||||
4. **Report classification breakdown** after each batch (e.g. "Batch 3: 12 verified, 30 stub, 3 n_a, 5 deferred").
|
||||
|
||||
## Classification Criteria Reference
|
||||
|
||||
| Status | Criteria |
|
||||
|--------|----------|
|
||||
| `verified` | .NET method exists with non-trivial logic that matches the Go implementation's behavior |
|
||||
| `stub` | .NET method exists but is `throw new NotImplementedException()`, empty, or only **partially** implemented (has structure but missing significant logic) |
|
||||
| `n_a` | Go feature doesn't apply to .NET — .NET uses a different approach (e.g. Go logging → .NET uses `Microsoft.Extensions.Logging`) |
|
||||
| `deferred` | .NET method doesn't exist at all, or classification requires the server running end-to-end |
|
||||
|
||||
## Key .NET Source Locations
|
||||
|
||||
When looking for a `dotnet_class`, search in these directories:
|
||||
|
||||
```
|
||||
dotnet/src/ZB.MOM.NatsNet.Server/
|
||||
Accounts/ — Account, AccountResolver, DirJwtStore, AccountTypes
|
||||
Auth/ — AuthHandler, JwtProcessor, CipherSuites, AuthTypes
|
||||
Config/ — ReloadOptions, ServerOptionsConfiguration, NatsJsonConverters
|
||||
Events/ — EventTypes
|
||||
Gateway/ — GatewayTypes
|
||||
Internal/ — Subscription, WaitGroup, ClosedRingBuffer, RateCounter, DataStructures/
|
||||
JetStream/ — MemStore, ConsumerMemStore, FileStore, FileStoreTypes, MessageBlock
|
||||
JetStreamTypes, JetStreamApiTypes, JetStreamErrors, JetStreamVersioning
|
||||
NatsStream, NatsConsumer, RaftTypes, JetStreamClusterTypes
|
||||
LeafNode/ — LeafNodeTypes
|
||||
MessageTrace/ — MsgTraceTypes
|
||||
Monitor/ — MonitorTypes, MonitorSortOptions
|
||||
Mqtt/ — MqttConstants, MqttTypes, MqttHandler
|
||||
Protocol/ — ParserTypes, ProtocolParser, ProxyProtocol
|
||||
Routes/ — RouteTypes
|
||||
WebSocket/ — WebSocketConstants, WebSocketTypes
|
||||
NatsServer.cs, NatsServer.Auth.cs, NatsServer.Signals.cs, NatsServer.Init.cs
|
||||
NatsServer.Accounts.cs, NatsServer.Lifecycle.cs, NatsServer.Listeners.cs
|
||||
ClientConnection.cs, ClientTypes.cs, NatsMessageHeaders.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Verify starting state and PortTracker commands
|
||||
|
||||
**Files:** None — verification only.
|
||||
|
||||
**Step 1: Check how many unknown features exist**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Expected: Output shows ~3394 features. Note the total count at the bottom.
|
||||
|
||||
**Step 2: Verify batch-update dry-run works**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 150-152 --set-status verified --db porting.db
|
||||
```
|
||||
|
||||
Expected: Preview output showing 3 features. Should say "Would affect 3 items:" and "Add --execute to apply these changes." Do NOT execute — this is just confirming the tool works.
|
||||
|
||||
**Step 3: Record the starting count**
|
||||
|
||||
Note the total unknown count. This is your baseline. After all batches complete, the count should be 0.
|
||||
|
||||
---
|
||||
|
||||
### Task N (repeat for N=1 through 68): Process batch of 50 unknown features
|
||||
|
||||
> **This task is a template.** Repeat it until zero unknown features remain. Each execution processes the next 50.
|
||||
|
||||
**Files:** None — classification only, no code changes.
|
||||
|
||||
**Step 1: Fetch the next 50 unknown features**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status unknown --db porting.db
|
||||
```
|
||||
|
||||
From the output, take the **first 50 feature IDs**. Note the `dotnet_class` and `dotnet_method` columns for each.
|
||||
|
||||
**Step 2: For each feature, inspect the .NET implementation**
|
||||
|
||||
For each of the 50 features:
|
||||
|
||||
1. **Find the .NET source file** — use `Grep` to search for the class:
|
||||
```
|
||||
Grep pattern: "class {dotnet_class}" path: dotnet/src/ZB.MOM.NatsNet.Server/
|
||||
```
|
||||
|
||||
2. **Find the method** — search within that file for the method name:
|
||||
```
|
||||
Grep pattern: "{dotnet_method}" path: {the file found above}
|
||||
```
|
||||
|
||||
3. **Read the method body** — use `Read` to view the method implementation.
|
||||
|
||||
4. **Classify the feature:**
|
||||
- If the method has real, non-trivial logic matching the Go behavior → `verified`
|
||||
- If the method is `throw new NotImplementedException()`, empty, or only partially there → `stub`
|
||||
- If the Go feature has a .NET-native replacement (e.g., Go's custom logging → `Microsoft.Extensions.Logging`, Go's `sync.Mutex` → C#'s `Lock`) → `n_a`
|
||||
- If the method doesn't exist in the .NET codebase at all → `deferred`
|
||||
|
||||
**Efficiency tip:** Features from the same `dotnet_class` should be inspected together — read the .NET file once and classify all features from that class in the batch.
|
||||
|
||||
**Step 3: Group IDs by classification result**
|
||||
|
||||
After inspecting all 50, organize the IDs into groups:
|
||||
|
||||
```
|
||||
verified_ids: 2068,2069,2070,2071,...
|
||||
stub_ids: 2080,2081,...
|
||||
n_a_ids: 2090,...
|
||||
deferred_ids: 2095,2096,...
|
||||
```
|
||||
|
||||
**Step 4: Dry-run each group (MANDATORY — DO NOT SKIP)**
|
||||
|
||||
Run the dry-run for EACH status group. Review the output carefully.
|
||||
|
||||
```bash
|
||||
# Dry-run verified
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {verified_ids} --set-status verified --db porting.db
|
||||
|
||||
# Dry-run stub
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {stub_ids} --set-status stub --db porting.db
|
||||
|
||||
# Dry-run n_a (include reason in notes)
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {n_a_ids} --set-status n_a --set-notes "{reason}" --db porting.db
|
||||
|
||||
# Dry-run deferred
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {deferred_ids} --set-status deferred --db porting.db
|
||||
```
|
||||
|
||||
Check that:
|
||||
- The feature names in the preview match what you inspected
|
||||
- The count per group adds up to 50 (or the batch size)
|
||||
- No unexpected features appear
|
||||
|
||||
**Step 5: Execute each group**
|
||||
|
||||
Only after verifying ALL dry-runs look correct:
|
||||
|
||||
```bash
|
||||
# Execute verified
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {verified_ids} --set-status verified --execute --db porting.db
|
||||
|
||||
# Execute stub
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {stub_ids} --set-status stub --execute --db porting.db
|
||||
|
||||
# Execute n_a (with notes)
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {n_a_ids} --set-status n_a --set-notes "{reason}" --execute --db porting.db
|
||||
|
||||
# Execute deferred
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {deferred_ids} --set-status deferred --execute --db porting.db
|
||||
```
|
||||
|
||||
**Step 6: Verify remaining count decreased**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Confirm the total decreased by ~50 from the previous batch.
|
||||
|
||||
**Step 7: Report batch summary**
|
||||
|
||||
Print: `Batch N: X verified, Y stub, Z n_a, W deferred (Total remaining: NNNN)`
|
||||
|
||||
---
|
||||
|
||||
### Task 69: Final verification and report
|
||||
|
||||
**Files:** None — verification only.
|
||||
|
||||
**Step 1: Confirm zero unknown features remain**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
|
||||
```
|
||||
|
||||
Expected: `Total: 0 features`
|
||||
|
||||
**Step 2: Generate the full status breakdown**
|
||||
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status verified --db porting.db
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status stub --db porting.db
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status n_a --db porting.db
|
||||
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status deferred --db porting.db
|
||||
```
|
||||
|
||||
Note the count for each status.
|
||||
|
||||
**Step 3: Generate updated porting report**
|
||||
|
||||
```bash
|
||||
./reports/generate-report.sh
|
||||
```
|
||||
|
||||
**Step 4: Commit the updated report**
|
||||
|
||||
```bash
|
||||
git add reports/
|
||||
git commit -m "chore: update porting report after feature status audit"
|
||||
```
|
||||
|
||||
**Step 5: Print final summary**
|
||||
|
||||
```
|
||||
Feature Status Audit Complete
|
||||
=============================
|
||||
Total features audited: 3394
|
||||
verified: NNNN
|
||||
stub: NNNN
|
||||
n_a: NNNN
|
||||
deferred: NNNN
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-27-feature-status-audit-plan.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Verify starting state and PortTracker commands", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1-68: Process batches of 50 unknown features (repeating template)", "status": "pending", "blockedBy": [0], "note": "This is a repeating task — execute the template from the plan 68 times until 0 unknown features remain"},
|
||||
{"id": 69, "subject": "Task 69: Final verification and report", "status": "pending", "blockedBy": [1]}
|
||||
],
|
||||
"lastUpdated": "2026-02-27T00:00:00Z"
|
||||
}
|
||||
120
docs/plans/2026-02-27-porttracker-batch-design.md
Normal file
120
docs/plans/2026-02-27-porttracker-batch-design.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# PortTracker Batch Operations Design
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The PortTracker CLI only supports one-at-a-time operations for status updates, mappings, and N/A marking. With ~3700 features and ~3300 tests, bulk operations require dropping to raw `sqlite3` commands. This is error-prone and bypasses any validation the CLI could provide.
|
||||
|
||||
## Design
|
||||
|
||||
### Approach
|
||||
|
||||
Add `batch-update` and `batch-map` subcommands under each existing entity command (`feature`, `test`, `module`, `library`). All batch commands share a common filter + dry-run infrastructure.
|
||||
|
||||
### Shared Batch Infrastructure
|
||||
|
||||
A new `BatchFilters` static class in `Commands/BatchFilters.cs` provides:
|
||||
|
||||
**Filter Options** (combined with AND logic):
|
||||
- `--ids <range>` — ID range like `100-200`, comma-separated `1,5,10`, or mixed `1-5,10,20-25`
|
||||
- `--module <id>` — filter by module_id (feature/test only)
|
||||
- `--status <status>` — filter by current status value
|
||||
|
||||
**Dry-Run Default:**
|
||||
- Without `--execute`, commands show a preview: "Would affect N items:" + table of matching rows
|
||||
- With `--execute`, changes are applied inside a transaction and "Updated N items." is printed
|
||||
- At least one filter is required (no accidental "update everything" with zero filters)
|
||||
|
||||
**Shared Methods:**
|
||||
- `AddFilterOptions(Command cmd, bool includeModuleFilter)` — adds the common options to a command
|
||||
- `BuildWhereClause(...)` — returns SQL WHERE clause + parameters from parsed filter values
|
||||
- `PreviewOrExecute(Database db, string table, string selectSql, string updateSql, params[], bool execute)` — handles dry-run preview vs actual execution
|
||||
|
||||
### Feature Batch Commands
|
||||
|
||||
**`feature batch-update`**
|
||||
- Filters: `--ids`, `--module`, `--status`
|
||||
- Setters: `--set-status` (required), `--set-notes` (optional)
|
||||
- Flag: `--execute`
|
||||
|
||||
**`feature batch-map`**
|
||||
- Filters: `--ids`, `--module`, `--status`
|
||||
- Setters: `--set-project`, `--set-class`, `--set-method` (at least one required)
|
||||
- Flag: `--execute`
|
||||
|
||||
### Test Batch Commands
|
||||
|
||||
**`test batch-update`**
|
||||
- Filters: `--ids`, `--module`, `--status`
|
||||
- Setters: `--set-status` (required), `--set-notes` (optional)
|
||||
- Flag: `--execute`
|
||||
|
||||
**`test batch-map`**
|
||||
- Filters: `--ids`, `--module`, `--status`
|
||||
- Setters: `--set-project`, `--set-class`, `--set-method` (at least one required)
|
||||
- Flag: `--execute`
|
||||
|
||||
### Module Batch Commands
|
||||
|
||||
**`module batch-update`**
|
||||
- Filters: `--ids`, `--status`
|
||||
- Setters: `--set-status` (required), `--set-notes` (optional)
|
||||
- Flag: `--execute`
|
||||
|
||||
**`module batch-map`**
|
||||
- Filters: `--ids`, `--status`
|
||||
- Setters: `--set-project`, `--set-namespace`, `--set-class` (at least one required)
|
||||
- Flag: `--execute`
|
||||
|
||||
### Library Batch Commands
|
||||
|
||||
**`library batch-update`**
|
||||
- Filters: `--ids`, `--status`
|
||||
- Setters: `--set-status` (required), `--set-notes` (optional, maps to `dotnet_usage_notes`)
|
||||
- Flag: `--execute`
|
||||
|
||||
**`library batch-map`**
|
||||
- Filters: `--ids`, `--status`
|
||||
- Setters: `--set-package`, `--set-namespace`, `--set-notes` (at least one required)
|
||||
- Flag: `--execute`
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Preview: which features in module 5 are not_started?
|
||||
porttracker feature batch-update --module 5 --status not_started --set-status deferred
|
||||
|
||||
# Execute: defer all features in module 5 with a reason
|
||||
porttracker feature batch-update --module 5 --status not_started --set-status deferred --set-notes "needs server runtime" --execute
|
||||
|
||||
# Execute: mark tests 500-750 as deferred
|
||||
porttracker test batch-update --ids 500-750 --set-status deferred --set-notes "server-integration" --execute
|
||||
|
||||
# Execute: batch-map all features in module 3 to a .NET project
|
||||
porttracker feature batch-map --module 3 --set-project "ZB.MOM.NatsNet.Server" --execute
|
||||
|
||||
# Preview: what libraries are unmapped?
|
||||
porttracker library batch-update --status not_mapped --set-status mapped
|
||||
|
||||
# Execute: batch-map libraries
|
||||
porttracker library batch-map --ids 1-20 --set-package "Microsoft.Extensions.Logging" --set-namespace "Microsoft.Extensions.Logging" --execute
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `Commands/BatchFilters.cs` | New — shared filter options, WHERE builder, preview/execute logic |
|
||||
| `Commands/FeatureCommands.cs` | Add `batch-update` and `batch-map` subcommands |
|
||||
| `Commands/TestCommands.cs` | Add `batch-update` and `batch-map` subcommands |
|
||||
| `Commands/ModuleCommands.cs` | Add `batch-update` and `batch-map` subcommands |
|
||||
| `Commands/LibraryCommands.cs` | Add `batch-update` and `batch-map` subcommands |
|
||||
| `Data/Database.cs` | Add `ExecuteInTransaction` helper for batch safety |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No batch create or batch delete — not needed for the porting workflow
|
||||
- No raw `--where` SQL escape hatch — structured filters cover all use cases
|
||||
- No interactive y/n prompts — dry-run + `--execute` flag is sufficient and scriptable
|
||||
919
docs/plans/2026-02-27-porttracker-batch-plan.md
Normal file
919
docs/plans/2026-02-27-porttracker-batch-plan.md
Normal file
@@ -0,0 +1,919 @@
|
||||
# PortTracker Batch Operations Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add batch-update and batch-map subcommands to all PortTracker entity commands (feature, test, module, library) with shared filter infrastructure and dry-run-by-default safety.
|
||||
|
||||
**Architecture:** A shared `BatchFilters` static class provides reusable filter options (`--ids`, `--module`, `--status`), WHERE clause building, and the dry-run/execute pattern. Each entity command file gets two new subcommands that delegate filtering and execution to `BatchFilters`. The `Database` class gets an `ExecuteInTransaction` helper.
|
||||
|
||||
**Tech Stack:** .NET 10, System.CommandLine v3 preview, Microsoft.Data.Sqlite
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-27-porttracker-batch-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Add ExecuteInTransaction to Database
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/NatsNet.PortTracker/Data/Database.cs:73` (before Dispose)
|
||||
|
||||
**Step 1: Add the method**
|
||||
|
||||
Add this method to `Database.cs` before the `Dispose()` method (line 73):
|
||||
|
||||
```csharp
|
||||
public int ExecuteInTransaction(string sql, params (string name, object? value)[] parameters)
|
||||
{
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
using var cmd = CreateCommand(sql);
|
||||
cmd.Transaction = transaction;
|
||||
foreach (var (name, value) in parameters)
|
||||
cmd.Parameters.AddWithValue(name, value ?? DBNull.Value);
|
||||
var affected = cmd.ExecuteNonQuery();
|
||||
transaction.Commit();
|
||||
return affected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Data/Database.cs
|
||||
git commit -m "feat(porttracker): add ExecuteInTransaction to Database"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create BatchFilters shared infrastructure
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/NatsNet.PortTracker/Commands/BatchFilters.cs`
|
||||
|
||||
**Step 1: Create the file**
|
||||
|
||||
Create `tools/NatsNet.PortTracker/Commands/BatchFilters.cs` with this content:
|
||||
|
||||
```csharp
|
||||
using System.CommandLine;
|
||||
using NatsNet.PortTracker.Data;
|
||||
|
||||
namespace NatsNet.PortTracker.Commands;
|
||||
|
||||
public static class BatchFilters
|
||||
{
|
||||
public static Option<string?> IdsOption() => new("--ids")
|
||||
{
|
||||
Description = "ID range: 100-200, 1,5,10, or mixed 1-5,10,20-25"
|
||||
};
|
||||
|
||||
public static Option<int?> ModuleOption() => new("--module")
|
||||
{
|
||||
Description = "Filter by module ID"
|
||||
};
|
||||
|
||||
public static Option<string?> StatusOption() => new("--status")
|
||||
{
|
||||
Description = "Filter by current status"
|
||||
};
|
||||
|
||||
public static Option<bool> ExecuteOption() => new("--execute")
|
||||
{
|
||||
Description = "Actually apply changes (default is dry-run preview)",
|
||||
DefaultValueFactory = _ => false
|
||||
};
|
||||
|
||||
public static void AddFilterOptions(Command cmd, bool includeModuleFilter)
|
||||
{
|
||||
cmd.Add(IdsOption());
|
||||
if (includeModuleFilter)
|
||||
cmd.Add(ModuleOption());
|
||||
cmd.Add(StatusOption());
|
||||
cmd.Add(ExecuteOption());
|
||||
}
|
||||
|
||||
public static List<int> ParseIds(string? idsSpec)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(idsSpec)) return [];
|
||||
|
||||
var ids = new List<int>();
|
||||
foreach (var part in idsSpec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (part.Contains('-'))
|
||||
{
|
||||
var range = part.Split('-', 2);
|
||||
if (int.TryParse(range[0], out var start) && int.TryParse(range[1], out var end))
|
||||
{
|
||||
for (var i = start; i <= end; i++)
|
||||
ids.Add(i);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: invalid range '{part}', skipping.");
|
||||
}
|
||||
}
|
||||
else if (int.TryParse(part, out var id))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: invalid ID '{part}', skipping.");
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static (string whereClause, List<(string name, object? value)> parameters) BuildWhereClause(
|
||||
string? idsSpec, int? moduleId, string? status, string idColumn = "id", string moduleColumn = "module_id")
|
||||
{
|
||||
var clauses = new List<string>();
|
||||
var parameters = new List<(string name, object? value)>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(idsSpec))
|
||||
{
|
||||
var ids = ParseIds(idsSpec);
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
var placeholders = new List<string>();
|
||||
for (var i = 0; i < ids.Count; i++)
|
||||
{
|
||||
placeholders.Add($"@id{i}");
|
||||
parameters.Add(($"@id{i}", ids[i]));
|
||||
}
|
||||
clauses.Add($"{idColumn} IN ({string.Join(", ", placeholders)})");
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleId is not null)
|
||||
{
|
||||
clauses.Add($"{moduleColumn} = @moduleFilter");
|
||||
parameters.Add(("@moduleFilter", moduleId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
clauses.Add("status = @statusFilter");
|
||||
parameters.Add(("@statusFilter", status));
|
||||
}
|
||||
|
||||
if (clauses.Count == 0)
|
||||
return ("", parameters);
|
||||
|
||||
return (" WHERE " + string.Join(" AND ", clauses), parameters);
|
||||
}
|
||||
|
||||
public static void PreviewOrExecute(
|
||||
Database db,
|
||||
string table,
|
||||
string displayColumns,
|
||||
string updateSetClause,
|
||||
List<(string name, object? value)> updateParams,
|
||||
string whereClause,
|
||||
List<(string name, object? value)> filterParams,
|
||||
bool execute)
|
||||
{
|
||||
// Count matching rows
|
||||
var countSql = $"SELECT COUNT(*) FROM {table}{whereClause}";
|
||||
var count = db.ExecuteScalar<long>(countSql, filterParams.ToArray());
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
Console.WriteLine("No items match the specified filters.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview
|
||||
var previewSql = $"SELECT {displayColumns} FROM {table}{whereClause} ORDER BY id";
|
||||
var rows = db.Query(previewSql, filterParams.ToArray());
|
||||
|
||||
if (!execute)
|
||||
{
|
||||
Console.WriteLine($"Would affect {count} items:");
|
||||
Console.WriteLine();
|
||||
PrintPreviewTable(rows);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Add --execute to apply these changes.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute
|
||||
var allParams = new List<(string name, object? value)>();
|
||||
allParams.AddRange(updateParams);
|
||||
allParams.AddRange(filterParams);
|
||||
|
||||
var updateSql = $"UPDATE {table} SET {updateSetClause}{whereClause}";
|
||||
var affected = db.ExecuteInTransaction(updateSql, allParams.ToArray());
|
||||
Console.WriteLine($"Updated {affected} items.");
|
||||
}
|
||||
|
||||
private static void PrintPreviewTable(List<Dictionary<string, object?>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return;
|
||||
|
||||
var columns = rows[0].Keys.ToList();
|
||||
var widths = columns.Select(c => c.Length).ToList();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
var val = row[columns[i]]?.ToString() ?? "";
|
||||
if (val.Length > widths[i]) widths[i] = Math.Min(val.Length, 40);
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
var header = string.Join(" ", columns.Select((c, i) => Truncate(c, widths[i]).PadRight(widths[i])));
|
||||
Console.WriteLine(header);
|
||||
Console.WriteLine(new string('-', header.Length));
|
||||
|
||||
// Rows (cap at 50 for preview)
|
||||
var displayRows = rows.Take(50).ToList();
|
||||
foreach (var row in displayRows)
|
||||
{
|
||||
var line = string.Join(" ", columns.Select((c, i) =>
|
||||
Truncate(row[c]?.ToString() ?? "", widths[i]).PadRight(widths[i])));
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
if (rows.Count > 50)
|
||||
Console.WriteLine($" ... and {rows.Count - 50} more");
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int maxLen)
|
||||
{
|
||||
return s.Length <= maxLen ? s : s[..(maxLen - 2)] + "..";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Commands/BatchFilters.cs
|
||||
git commit -m "feat(porttracker): add BatchFilters shared infrastructure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add batch commands to FeatureCommands
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/NatsNet.PortTracker/Commands/FeatureCommands.cs:169-175`
|
||||
|
||||
**Step 1: Add batch-update and batch-map subcommands**
|
||||
|
||||
In `FeatureCommands.cs`, insert the batch commands before the `return featureCommand;` line (line 175). Add them after the existing `featureCommand.Add(naCmd);` at line 173.
|
||||
|
||||
Replace lines 169-175 with:
|
||||
|
||||
```csharp
|
||||
featureCommand.Add(listCmd);
|
||||
featureCommand.Add(showCmd);
|
||||
featureCommand.Add(updateCmd);
|
||||
featureCommand.Add(mapCmd);
|
||||
featureCommand.Add(naCmd);
|
||||
featureCommand.Add(CreateBatchUpdate(dbOption));
|
||||
featureCommand.Add(CreateBatchMap(dbOption));
|
||||
|
||||
return featureCommand;
|
||||
```
|
||||
|
||||
Then add these two static methods to the class (before the `Truncate` method at line 178):
|
||||
|
||||
```csharp
|
||||
private static Command CreateBatchUpdate(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-update", "Bulk update feature status");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var moduleOpt = BatchFilters.ModuleOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
|
||||
var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(moduleOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setStatus);
|
||||
cmd.Add(setNotes);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var module = parseResult.GetValue(moduleOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var newStatus = parseResult.GetValue(setStatus)!;
|
||||
var notes = parseResult.GetValue(setNotes);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
|
||||
|
||||
var setClauses = new List<string> { "status = @newStatus" };
|
||||
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
|
||||
if (notes is not null)
|
||||
{
|
||||
setClauses.Add("notes = @newNotes");
|
||||
updateParams.Add(("@newNotes", notes));
|
||||
}
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "features",
|
||||
"id, name, status, module_id, notes",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command CreateBatchMap(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-map", "Bulk map features to .NET methods");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var moduleOpt = BatchFilters.ModuleOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setProject = new Option<string?>("--set-project") { Description = ".NET project" };
|
||||
var setClass = new Option<string?>("--set-class") { Description = ".NET class" };
|
||||
var setMethod = new Option<string?>("--set-method") { Description = ".NET method" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(moduleOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setProject);
|
||||
cmd.Add(setClass);
|
||||
cmd.Add(setMethod);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var module = parseResult.GetValue(moduleOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var project = parseResult.GetValue(setProject);
|
||||
var cls = parseResult.GetValue(setClass);
|
||||
var method = parseResult.GetValue(setMethod);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
|
||||
return;
|
||||
}
|
||||
if (project is null && cls is null && method is null)
|
||||
{
|
||||
Console.WriteLine("Error: at least one of --set-project, --set-class, --set-method is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
|
||||
|
||||
var setClauses = new List<string>();
|
||||
var updateParams = new List<(string, object?)>();
|
||||
if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
|
||||
if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
|
||||
if (method is not null) { setClauses.Add("dotnet_method = @setMethod"); updateParams.Add(("@setMethod", method)); }
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "features",
|
||||
"id, name, status, dotnet_project, dotnet_class, dotnet_method",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
**Step 3: Smoke test dry-run**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --module 1 --status not_started --set-status deferred --db porting.db`
|
||||
Expected: Preview output showing matching features (or "No items match").
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Commands/FeatureCommands.cs
|
||||
git commit -m "feat(porttracker): add feature batch-update and batch-map commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add batch commands to TestCommands
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/NatsNet.PortTracker/Commands/TestCommands.cs:130-135`
|
||||
|
||||
**Step 1: Add batch-update and batch-map subcommands**
|
||||
|
||||
In `TestCommands.cs`, replace lines 130-135 with:
|
||||
|
||||
```csharp
|
||||
testCommand.Add(listCmd);
|
||||
testCommand.Add(showCmd);
|
||||
testCommand.Add(updateCmd);
|
||||
testCommand.Add(mapCmd);
|
||||
testCommand.Add(CreateBatchUpdate(dbOption));
|
||||
testCommand.Add(CreateBatchMap(dbOption));
|
||||
|
||||
return testCommand;
|
||||
```
|
||||
|
||||
Then add these two static methods before the `Truncate` method (line 138):
|
||||
|
||||
```csharp
|
||||
private static Command CreateBatchUpdate(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-update", "Bulk update test status");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var moduleOpt = BatchFilters.ModuleOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
|
||||
var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(moduleOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setStatus);
|
||||
cmd.Add(setNotes);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var module = parseResult.GetValue(moduleOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var newStatus = parseResult.GetValue(setStatus)!;
|
||||
var notes = parseResult.GetValue(setNotes);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
|
||||
|
||||
var setClauses = new List<string> { "status = @newStatus" };
|
||||
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
|
||||
if (notes is not null)
|
||||
{
|
||||
setClauses.Add("notes = @newNotes");
|
||||
updateParams.Add(("@newNotes", notes));
|
||||
}
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "unit_tests",
|
||||
"id, name, status, module_id, notes",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command CreateBatchMap(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-map", "Bulk map tests to .NET test methods");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var moduleOpt = BatchFilters.ModuleOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setProject = new Option<string?>("--set-project") { Description = ".NET test project" };
|
||||
var setClass = new Option<string?>("--set-class") { Description = ".NET test class" };
|
||||
var setMethod = new Option<string?>("--set-method") { Description = ".NET test method" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(moduleOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setProject);
|
||||
cmd.Add(setClass);
|
||||
cmd.Add(setMethod);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var module = parseResult.GetValue(moduleOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var project = parseResult.GetValue(setProject);
|
||||
var cls = parseResult.GetValue(setClass);
|
||||
var method = parseResult.GetValue(setMethod);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
|
||||
return;
|
||||
}
|
||||
if (project is null && cls is null && method is null)
|
||||
{
|
||||
Console.WriteLine("Error: at least one of --set-project, --set-class, --set-method is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
|
||||
|
||||
var setClauses = new List<string>();
|
||||
var updateParams = new List<(string, object?)>();
|
||||
if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
|
||||
if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
|
||||
if (method is not null) { setClauses.Add("dotnet_method = @setMethod"); updateParams.Add(("@setMethod", method)); }
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "unit_tests",
|
||||
"id, name, status, dotnet_project, dotnet_class, dotnet_method",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
**Step 3: Smoke test dry-run**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- test batch-update --status not_started --set-status deferred --db porting.db`
|
||||
Expected: Preview output showing matching tests (or "No items match").
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Commands/TestCommands.cs
|
||||
git commit -m "feat(porttracker): add test batch-update and batch-map commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add batch commands to ModuleCommands
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/NatsNet.PortTracker/Commands/ModuleCommands.cs:145-152`
|
||||
|
||||
**Step 1: Add batch-update and batch-map subcommands**
|
||||
|
||||
In `ModuleCommands.cs`, replace lines 145-152 with:
|
||||
|
||||
```csharp
|
||||
moduleCommand.Add(listCmd);
|
||||
moduleCommand.Add(showCmd);
|
||||
moduleCommand.Add(updateCmd);
|
||||
moduleCommand.Add(mapCmd);
|
||||
moduleCommand.Add(naCmd);
|
||||
moduleCommand.Add(CreateBatchUpdate(dbOption));
|
||||
moduleCommand.Add(CreateBatchMap(dbOption));
|
||||
|
||||
return moduleCommand;
|
||||
}
|
||||
```
|
||||
|
||||
Then add these two static methods before the closing `}` of the class:
|
||||
|
||||
```csharp
|
||||
private static Command CreateBatchUpdate(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-update", "Bulk update module status");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
|
||||
var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setStatus);
|
||||
cmd.Add(setNotes);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var newStatus = parseResult.GetValue(setStatus)!;
|
||||
var notes = parseResult.GetValue(setNotes);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
|
||||
|
||||
var setClauses = new List<string> { "status = @newStatus" };
|
||||
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
|
||||
if (notes is not null)
|
||||
{
|
||||
setClauses.Add("notes = @newNotes");
|
||||
updateParams.Add(("@newNotes", notes));
|
||||
}
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "modules",
|
||||
"id, name, status, notes",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command CreateBatchMap(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-map", "Bulk map modules to .NET projects");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setProject = new Option<string?>("--set-project") { Description = ".NET project" };
|
||||
var setNamespace = new Option<string?>("--set-namespace") { Description = ".NET namespace" };
|
||||
var setClass = new Option<string?>("--set-class") { Description = ".NET class" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setProject);
|
||||
cmd.Add(setNamespace);
|
||||
cmd.Add(setClass);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var project = parseResult.GetValue(setProject);
|
||||
var ns = parseResult.GetValue(setNamespace);
|
||||
var cls = parseResult.GetValue(setClass);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
|
||||
return;
|
||||
}
|
||||
if (project is null && ns is null && cls is null)
|
||||
{
|
||||
Console.WriteLine("Error: at least one of --set-project, --set-namespace, --set-class is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
|
||||
|
||||
var setClauses = new List<string>();
|
||||
var updateParams = new List<(string, object?)>();
|
||||
if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
|
||||
if (ns is not null) { setClauses.Add("dotnet_namespace = @setNamespace"); updateParams.Add(("@setNamespace", ns)); }
|
||||
if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "modules",
|
||||
"id, name, status, dotnet_project, dotnet_namespace, dotnet_class",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Commands/ModuleCommands.cs
|
||||
git commit -m "feat(porttracker): add module batch-update and batch-map commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add batch commands to LibraryCommands
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/NatsNet.PortTracker/Commands/LibraryCommands.cs:86-91`
|
||||
|
||||
**Step 1: Add batch-update and batch-map subcommands**
|
||||
|
||||
In `LibraryCommands.cs`, replace lines 86-91 with:
|
||||
|
||||
```csharp
|
||||
libraryCommand.Add(listCmd);
|
||||
libraryCommand.Add(mapCmd);
|
||||
libraryCommand.Add(suggestCmd);
|
||||
libraryCommand.Add(CreateBatchUpdate(dbOption));
|
||||
libraryCommand.Add(CreateBatchMap(dbOption));
|
||||
|
||||
return libraryCommand;
|
||||
}
|
||||
```
|
||||
|
||||
Then add these two static methods before the `Truncate` method:
|
||||
|
||||
```csharp
|
||||
private static Command CreateBatchUpdate(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-update", "Bulk update library status");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
|
||||
var setNotes = new Option<string?>("--set-notes") { Description = "Usage notes to set" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setStatus);
|
||||
cmd.Add(setNotes);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var newStatus = parseResult.GetValue(setStatus)!;
|
||||
var notes = parseResult.GetValue(setNotes);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
|
||||
|
||||
var setClauses = new List<string> { "status = @newStatus" };
|
||||
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
|
||||
if (notes is not null)
|
||||
{
|
||||
setClauses.Add("dotnet_usage_notes = @newNotes");
|
||||
updateParams.Add(("@newNotes", notes));
|
||||
}
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "library_mappings",
|
||||
"id, go_import_path, status, dotnet_usage_notes",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command CreateBatchMap(Option<string> dbOption)
|
||||
{
|
||||
var cmd = new Command("batch-map", "Bulk map libraries to .NET packages");
|
||||
var idsOpt = BatchFilters.IdsOption();
|
||||
var statusOpt = BatchFilters.StatusOption();
|
||||
var executeOpt = BatchFilters.ExecuteOption();
|
||||
var setPackage = new Option<string?>("--set-package") { Description = ".NET NuGet package" };
|
||||
var setNamespace = new Option<string?>("--set-namespace") { Description = ".NET namespace" };
|
||||
var setNotes = new Option<string?>("--set-notes") { Description = "Usage notes" };
|
||||
|
||||
cmd.Add(idsOpt);
|
||||
cmd.Add(statusOpt);
|
||||
cmd.Add(executeOpt);
|
||||
cmd.Add(setPackage);
|
||||
cmd.Add(setNamespace);
|
||||
cmd.Add(setNotes);
|
||||
|
||||
cmd.SetAction(parseResult =>
|
||||
{
|
||||
var dbPath = parseResult.GetValue(dbOption)!;
|
||||
var ids = parseResult.GetValue(idsOpt);
|
||||
var status = parseResult.GetValue(statusOpt);
|
||||
var execute = parseResult.GetValue(executeOpt);
|
||||
var package = parseResult.GetValue(setPackage);
|
||||
var ns = parseResult.GetValue(setNamespace);
|
||||
var notes = parseResult.GetValue(setNotes);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
|
||||
return;
|
||||
}
|
||||
if (package is null && ns is null && notes is null)
|
||||
{
|
||||
Console.WriteLine("Error: at least one of --set-package, --set-namespace, --set-notes is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new Database(dbPath);
|
||||
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
|
||||
|
||||
var setClauses = new List<string>();
|
||||
var updateParams = new List<(string, object?)>();
|
||||
if (package is not null) { setClauses.Add("dotnet_package = @setPackage"); updateParams.Add(("@setPackage", package)); }
|
||||
if (ns is not null) { setClauses.Add("dotnet_namespace = @setNamespace"); updateParams.Add(("@setNamespace", ns)); }
|
||||
if (notes is not null) { setClauses.Add("dotnet_usage_notes = @setNotes"); updateParams.Add(("@setNotes", notes)); }
|
||||
|
||||
BatchFilters.PreviewOrExecute(db, "library_mappings",
|
||||
"id, go_import_path, status, dotnet_package, dotnet_namespace",
|
||||
string.Join(", ", setClauses), updateParams,
|
||||
whereClause, filterParams, execute);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/NatsNet.PortTracker/Commands/LibraryCommands.cs
|
||||
git commit -m "feat(porttracker): add library batch-update and batch-map commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: End-to-end smoke test
|
||||
|
||||
**Files:** None — testing only.
|
||||
|
||||
**Step 1: Test feature batch-update dry-run**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --status deferred --set-status deferred --db porting.db`
|
||||
Expected: Preview showing deferred features.
|
||||
|
||||
**Step 2: Test test batch-update dry-run**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- test batch-update --ids 1-5 --set-status verified --db porting.db`
|
||||
Expected: Preview showing tests 1-5.
|
||||
|
||||
**Step 3: Test module batch-update dry-run**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- module batch-update --status verified --set-status verified --db porting.db`
|
||||
Expected: Preview showing verified modules.
|
||||
|
||||
**Step 4: Test library batch-map dry-run**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- library batch-map --status mapped --set-package "test" --db porting.db`
|
||||
Expected: Preview showing mapped libraries.
|
||||
|
||||
**Step 5: Test error cases**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --set-status deferred --db porting.db`
|
||||
Expected: "Error: at least one filter (--ids, --module, --status) is required."
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-map --ids 1-5 --db porting.db`
|
||||
Expected: "Error: at least one of --set-project, --set-class, --set-method is required."
|
||||
|
||||
**Step 6: Test help output**
|
||||
|
||||
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --help`
|
||||
Expected: Shows all options with descriptions.
|
||||
|
||||
**Step 7: Final commit**
|
||||
|
||||
No code changes — this task is verification only. If any issues found, fix and commit with appropriate message.
|
||||
13
docs/plans/2026-02-27-porttracker-batch-plan.md.tasks.json
Normal file
13
docs/plans/2026-02-27-porttracker-batch-plan.md.tasks.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-27-porttracker-batch-plan.md",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 7, "subject": "Task 0: Add ExecuteInTransaction to Database", "status": "pending"},
|
||||
{"id": 1, "nativeId": 8, "subject": "Task 1: Create BatchFilters shared infrastructure", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeId": 9, "subject": "Task 2: Add batch commands to FeatureCommands", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 10, "subject": "Task 3: Add batch commands to TestCommands", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": 11, "subject": "Task 4: Add batch commands to ModuleCommands", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "nativeId": 12, "subject": "Task 5: Add batch commands to LibraryCommands", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "nativeId": 13, "subject": "Task 6: End-to-end smoke test", "status": "pending", "blockedBy": [2, 3, 4, 5]}
|
||||
],
|
||||
"lastUpdated": "2026-02-27T00:00:00Z"
|
||||
}
|
||||
63
docs/plans/2026-02-27-unit-test-audit-design.md
Normal file
63
docs/plans/2026-02-27-unit-test-audit-design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Unit Test Audit Extension Design
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the PortTracker `audit` command to classify unit tests (not just features) by inspecting .NET test source code with Roslyn.
|
||||
|
||||
## Architecture
|
||||
|
||||
Parameterize the existing audit pipeline (`AuditCommand` + `SourceIndexer` + `FeatureClassifier`) to support both `features` and `unit_tests` tables. No new files — the same indexer and classifier logic applies to test methods.
|
||||
|
||||
## CLI Interface
|
||||
|
||||
```
|
||||
dotnet run -- audit --type features|tests|all [--source <path>] [--module <id>] [--execute]
|
||||
```
|
||||
|
||||
| Flag | Default (features) | Default (tests) |
|
||||
|------|-------------------|-----------------|
|
||||
| `--type` | `features` | — |
|
||||
| `--source` | `dotnet/src/ZB.MOM.NatsNet.Server` | `dotnet/tests/ZB.MOM.NatsNet.Server.Tests` |
|
||||
| `--output` | `reports/audit-results.csv` | `reports/audit-results-tests.csv` |
|
||||
|
||||
- `--type all` runs both sequentially.
|
||||
- `--source` override works for either type.
|
||||
|
||||
## Changes Required
|
||||
|
||||
### AuditCommand.cs
|
||||
|
||||
1. Add `--type` option with values `features`, `tests`, `all`.
|
||||
2. Thread an `AuditTarget` (table name + default source + default output + display label) through `RunAudit` and `ApplyUpdates`.
|
||||
3. `--type all` calls `RunAudit` twice with different targets.
|
||||
4. `ApplyUpdates` uses the target's table name in UPDATE SQL.
|
||||
|
||||
### FeatureClassifier.cs
|
||||
|
||||
No changes. Same N/A lookup and classification logic applies to unit tests.
|
||||
|
||||
### SourceIndexer.cs
|
||||
|
||||
No changes. Already generic — just pass a different directory path.
|
||||
|
||||
## Pre-audit DB Reset
|
||||
|
||||
Before running the test audit, manually reset deferred tests to `unknown`:
|
||||
|
||||
```sql
|
||||
sqlite3 porting.db "UPDATE unit_tests SET status = 'unknown' WHERE status = 'deferred';"
|
||||
```
|
||||
|
||||
## Execution Sequence
|
||||
|
||||
1. Reset deferred tests: `sqlite3 porting.db "UPDATE unit_tests SET status = 'unknown' WHERE status = 'deferred';"`
|
||||
2. Run audit: `dotnet run -- audit --type tests --db porting.db --execute`
|
||||
3. Verify results and generate report.
|
||||
|
||||
## Classification Behavior for Tests
|
||||
|
||||
Same priority as features:
|
||||
1. **N/A**: Go method matches logging/signal patterns → `n_a`
|
||||
2. **Method found**: Test class + method exists in test project → `verified` or `stub`
|
||||
3. **Class exists, method missing**: → `deferred` ("method not found")
|
||||
4. **Class not found**: → `deferred` ("class not found")
|
||||
2130
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
Normal file
2130
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
Normal file
File diff suppressed because it is too large
Load Diff
525
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs
Normal file
525
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs
Normal file
@@ -0,0 +1,525 @@
|
||||
// Copyright 2018-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/accounts.go in the NATS server Go source.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// IAccountResolver
|
||||
// Mirrors Go AccountResolver interface (accounts.go ~line 4035).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Resolves and stores account JWTs by account public key name.
|
||||
/// Mirrors Go <c>AccountResolver</c> interface.
|
||||
/// </summary>
|
||||
public interface IAccountResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the JWT for the named account.
|
||||
/// Throws <see cref="InvalidOperationException"/> when the account is not found.
|
||||
/// Mirrors Go <c>AccountResolver.Fetch</c>.
|
||||
/// </summary>
|
||||
Task<string> FetchAsync(string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the JWT for the named account.
|
||||
/// Read-only implementations throw <see cref="NotSupportedException"/>.
|
||||
/// Mirrors Go <c>AccountResolver.Store</c>.
|
||||
/// </summary>
|
||||
Task StoreAsync(string name, string jwt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns true when no writes are permitted. Mirrors Go <c>IsReadOnly</c>.</summary>
|
||||
bool IsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Starts any background processing needed by the resolver (system subscriptions, timers, etc.).
|
||||
/// The <paramref name="server"/> parameter accepts an <c>object</c> to avoid a circular assembly
|
||||
/// reference; implementations should cast it to the concrete server type as needed.
|
||||
/// Mirrors Go <c>AccountResolver.Start</c>.
|
||||
/// </summary>
|
||||
void Start(object server);
|
||||
|
||||
/// <summary>Returns true when the resolver reacts to JWT update events. Mirrors Go <c>IsTrackingUpdate</c>.</summary>
|
||||
bool IsTrackingUpdate();
|
||||
|
||||
/// <summary>Reloads state from the backing store. Mirrors Go <c>AccountResolver.Reload</c>.</summary>
|
||||
void Reload();
|
||||
|
||||
/// <summary>Releases resources held by the resolver. Mirrors Go <c>AccountResolver.Close</c>.</summary>
|
||||
void Close();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ResolverDefaultsOps
|
||||
// Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base that provides sensible no-op / read-only defaults for <see cref="IAccountResolver"/>
|
||||
/// so concrete implementations only need to override what they change.
|
||||
/// Mirrors Go <c>resolverDefaultsOpsImpl</c>.
|
||||
/// </summary>
|
||||
public abstract class ResolverDefaultsOps : IAccountResolver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<string> FetchAsync(string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Default store implementation — always throws because the base defaults to read-only.
|
||||
/// Mirrors Go <c>resolverDefaultsOpsImpl.Store</c>.
|
||||
/// </summary>
|
||||
public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException("store operation not supported");
|
||||
|
||||
/// <summary>Default: the resolver is read-only. Mirrors Go <c>resolverDefaultsOpsImpl.IsReadOnly</c>.</summary>
|
||||
public virtual bool IsReadOnly() => true;
|
||||
|
||||
/// <summary>Default: no-op start. Mirrors Go <c>resolverDefaultsOpsImpl.Start</c>.</summary>
|
||||
public virtual void Start(object server) { }
|
||||
|
||||
/// <summary>Default: does not track updates. Mirrors Go <c>resolverDefaultsOpsImpl.IsTrackingUpdate</c>.</summary>
|
||||
public virtual bool IsTrackingUpdate() => false;
|
||||
|
||||
/// <summary>Default: no-op reload. Mirrors Go <c>resolverDefaultsOpsImpl.Reload</c>.</summary>
|
||||
public virtual void Reload() { }
|
||||
|
||||
/// <summary>Default: no-op close. Mirrors Go <c>resolverDefaultsOpsImpl.Close</c>.</summary>
|
||||
public virtual void Close() { }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MemoryAccountResolver
|
||||
// Mirrors Go MemAccResolver (accounts.go ~line 4072).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An in-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// Primarily intended for testing.
|
||||
/// Mirrors Go <c>MemAccResolver</c>.
|
||||
/// </summary>
|
||||
public sealed class MemoryAccountResolver : ResolverDefaultsOps
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _store = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>In-memory resolver is not read-only.</summary>
|
||||
public override bool IsReadOnly() => false;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored JWT for <paramref name="name"/>, or throws
|
||||
/// <see cref="InvalidOperationException"/> when the account is unknown.
|
||||
/// Mirrors Go <c>MemAccResolver.Fetch</c>.
|
||||
/// </summary>
|
||||
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
if (_store.TryGetValue(name, out var jwt))
|
||||
{
|
||||
return Task.FromResult(jwt);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Account not found: {name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores <paramref name="jwt"/> for <paramref name="name"/>.
|
||||
/// Mirrors Go <c>MemAccResolver.Store</c>.
|
||||
/// </summary>
|
||||
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
|
||||
{
|
||||
_store[name] = jwt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UrlAccountResolver
|
||||
// Mirrors Go URLAccResolver (accounts.go ~line 4097).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An HTTP-based account resolver that fetches JWTs by appending the account public key
|
||||
/// to a configured base URL.
|
||||
/// Mirrors Go <c>URLAccResolver</c>.
|
||||
/// </summary>
|
||||
public sealed class UrlAccountResolver : ResolverDefaultsOps
|
||||
{
|
||||
// Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT.
|
||||
private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly string _url;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new URL resolver for the given <paramref name="url"/>.
|
||||
/// A trailing slash is appended when absent so that account names can be concatenated
|
||||
/// directly. An <see cref="HttpClient"/> is configured with connection-pooling
|
||||
/// settings that amortise TLS handshakes across requests, mirroring Go's custom
|
||||
/// <c>http.Transport</c>.
|
||||
/// Mirrors Go <c>NewURLAccResolver</c>.
|
||||
/// </summary>
|
||||
public UrlAccountResolver(string url)
|
||||
{
|
||||
if (!url.EndsWith('/'))
|
||||
{
|
||||
url += "/";
|
||||
}
|
||||
|
||||
_url = url;
|
||||
|
||||
// Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport.
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
MaxConnectionsPerServer = 10,
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = DefaultAccountFetchTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues an HTTP GET to the base URL with the account name appended, and returns
|
||||
/// the response body as the JWT string.
|
||||
/// Throws <see cref="InvalidOperationException"/> on a non-200 response.
|
||||
/// Mirrors Go <c>URLAccResolver.Fetch</c>.
|
||||
/// </summary>
|
||||
public override async Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var requestUrl = _url + name;
|
||||
HttpResponseMessage response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DirResOption — functional option for DirAccountResolver
|
||||
// Mirrors Go DirResOption func type (accounts.go ~line 4552).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A functional option that configures a <see cref="DirAccountResolver"/> instance.
|
||||
/// Mirrors Go <c>DirResOption</c> function type.
|
||||
/// </summary>
|
||||
public delegate void DirResOption(DirAccountResolver resolver);
|
||||
|
||||
/// <summary>
|
||||
/// Factory methods for commonly used <see cref="DirResOption"/> values.
|
||||
/// </summary>
|
||||
public static class DirResOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an option that overrides the default fetch timeout.
|
||||
/// <paramref name="timeout"/> must be positive.
|
||||
/// Mirrors Go <c>FetchTimeout</c> option constructor.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// Thrown at application time when <paramref name="timeout"/> is not positive.
|
||||
/// </exception>
|
||||
public static DirResOption FetchTimeout(TimeSpan timeout)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout),
|
||||
$"Fetch timeout {timeout} is too small");
|
||||
}
|
||||
|
||||
return resolver => resolver.FetchTimeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DirAccountResolver (stub)
|
||||
// Mirrors Go DirAccResolver (accounts.go ~line 4143).
|
||||
// Full system-subscription wiring is deferred to session 12.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A directory-backed account resolver that stores JWTs in a <see cref="DirJwtStore"/>
|
||||
/// and synchronises with peers via NATS system subjects.
|
||||
/// <para>
|
||||
/// The Start override that wires up system subscriptions and the periodic sync goroutine
|
||||
/// is a stub in this session; full implementation requires JetStream and system
|
||||
/// subscription support (session 12+).
|
||||
/// </para>
|
||||
/// Mirrors Go <c>DirAccResolver</c>.
|
||||
/// </summary>
|
||||
public class DirAccountResolver : ResolverDefaultsOps, IDisposable
|
||||
{
|
||||
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
|
||||
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Default sync interval — mirrors Go's fallback of 1 minute.
|
||||
private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>The underlying directory JWT store. Mirrors Go <c>DirAccResolver.DirJWTStore</c>.</summary>
|
||||
public DirJwtStore Store { get; }
|
||||
|
||||
/// <summary>Reference to the running server, set during <see cref="Start"/>. Mirrors Go <c>DirAccResolver.Server</c>.</summary>
|
||||
public object? Server { get; protected set; }
|
||||
|
||||
/// <summary>How often the resolver sends a sync (pack) request to peers. Mirrors Go <c>DirAccResolver.syncInterval</c>.</summary>
|
||||
public TimeSpan SyncInterval { get; protected set; }
|
||||
|
||||
/// <summary>Maximum time to wait for a remote JWT fetch. Mirrors Go <c>DirAccResolver.fetchTimeout</c>.</summary>
|
||||
public TimeSpan FetchTimeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new directory account resolver.
|
||||
/// <para>
|
||||
/// When <paramref name="limit"/> is zero it is promoted to <see cref="long.MaxValue"/> (unlimited).
|
||||
/// When <paramref name="syncInterval"/> is non-positive it defaults to one minute.
|
||||
/// </para>
|
||||
/// Mirrors Go <c>NewDirAccResolver</c>.
|
||||
/// </summary>
|
||||
/// <param name="path">Directory path for the JWT store.</param>
|
||||
/// <param name="limit">Maximum number of JWTs the store may hold (0 = unlimited).</param>
|
||||
/// <param name="syncInterval">How often to broadcast a sync/pack request to peers.</param>
|
||||
/// <param name="deleteType">Controls whether deletes are soft- or hard-deleted.</param>
|
||||
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
|
||||
public DirAccountResolver(
|
||||
string path,
|
||||
long limit,
|
||||
TimeSpan syncInterval,
|
||||
JwtDeleteType deleteType,
|
||||
params DirResOption[] opts)
|
||||
{
|
||||
if (limit == 0)
|
||||
{
|
||||
limit = long.MaxValue;
|
||||
}
|
||||
|
||||
if (syncInterval <= TimeSpan.Zero)
|
||||
{
|
||||
syncInterval = DefaultSyncInterval;
|
||||
}
|
||||
|
||||
Store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
path,
|
||||
shard: false,
|
||||
create: true,
|
||||
deleteType,
|
||||
expireCheck: TimeSpan.Zero,
|
||||
limit,
|
||||
evictOnLimit: false,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
SyncInterval = syncInterval;
|
||||
FetchTimeout = DefaultFetchTimeout;
|
||||
|
||||
Apply(opts);
|
||||
}
|
||||
|
||||
// Internal constructor used by CacheDirAccountResolver which supplies its own store.
|
||||
internal DirAccountResolver(
|
||||
DirJwtStore store,
|
||||
TimeSpan syncInterval,
|
||||
TimeSpan fetchTimeout)
|
||||
{
|
||||
Store = store;
|
||||
SyncInterval = syncInterval;
|
||||
FetchTimeout = fetchTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a sequence of functional options to this resolver.
|
||||
/// Mirrors Go <c>DirAccResolver.apply</c>.
|
||||
/// </summary>
|
||||
protected void Apply(IEnumerable<DirResOption> opts)
|
||||
{
|
||||
foreach (var opt in opts)
|
||||
{
|
||||
opt(this);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IAccountResolver overrides
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// DirAccountResolver is not read-only.
|
||||
/// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false
|
||||
/// from the concrete behaviour (store is writable).
|
||||
/// </summary>
|
||||
public override bool IsReadOnly() => false;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks updates (reacts to JWT change events).
|
||||
/// Mirrors Go <c>DirAccResolver.IsTrackingUpdate</c>.
|
||||
/// </summary>
|
||||
public override bool IsTrackingUpdate() => true;
|
||||
|
||||
/// <summary>
|
||||
/// Reloads state from the backing <see cref="DirJwtStore"/>.
|
||||
/// Mirrors Go <c>DirAccResolver.Reload</c>.
|
||||
/// </summary>
|
||||
public override void Reload() => Store.Reload();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the JWT for <paramref name="name"/> from the local <see cref="DirJwtStore"/>.
|
||||
/// Throws <see cref="InvalidOperationException"/> when the account is not found locally.
|
||||
/// <para>
|
||||
/// Note: the Go implementation falls back to <c>srv.fetch</c> (a cluster-wide lookup) when
|
||||
/// the local store misses. That fallback requires system subscriptions and is deferred to
|
||||
/// session 12. For now this method only consults the local store.
|
||||
/// </para>
|
||||
/// Mirrors Go <c>DirAccResolver.Fetch</c> (local path only).
|
||||
/// </summary>
|
||||
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var theJwt = Store.LoadAcc(name);
|
||||
if (!string.IsNullOrEmpty(theJwt))
|
||||
{
|
||||
return Task.FromResult(theJwt);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Account not found: {name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores <paramref name="jwt"/> under <paramref name="name"/>, keeping the newer JWT
|
||||
/// when a conflicting entry already exists.
|
||||
/// Mirrors Go <c>DirAccResolver.Store</c> (delegates to <c>saveIfNewer</c>).
|
||||
/// </summary>
|
||||
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
|
||||
{
|
||||
// SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation.
|
||||
Store.SaveAcc(name, jwt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts background system subscriptions and the periodic sync timer.
|
||||
/// <para>
|
||||
/// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack
|
||||
/// requests, cluster synchronisation, and the periodic pack broadcast goroutine.
|
||||
/// </para>
|
||||
/// Mirrors Go <c>DirAccResolver.Start</c>.
|
||||
/// </summary>
|
||||
public override void Start(object server)
|
||||
{
|
||||
Server = server;
|
||||
// TODO (session 12): set up system subscriptions and periodic sync timer.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops background processing and closes the <see cref="DirJwtStore"/>.
|
||||
/// Mirrors Go <c>AccountResolver.Close</c> (no explicit Go override; store is closed
|
||||
/// by the server shutdown path).
|
||||
/// </summary>
|
||||
public override void Close() => Store.Close();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => Store.Dispose();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CacheDirAccountResolver (stub)
|
||||
// Mirrors Go CacheDirAccResolver (accounts.go ~line 4594).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A caching variant of <see cref="DirAccountResolver"/> that uses a TTL-based expiring
|
||||
/// store so that fetched JWTs are automatically evicted after <see cref="Ttl"/>.
|
||||
/// <para>
|
||||
/// The Start override that wires up system subscriptions is a stub in this session;
|
||||
/// full implementation requires system subscription support (session 12+).
|
||||
/// </para>
|
||||
/// Mirrors Go <c>CacheDirAccResolver</c>.
|
||||
/// </summary>
|
||||
public sealed class CacheDirAccountResolver : DirAccountResolver
|
||||
{
|
||||
// Default cache limit — mirrors Go's fallback of 1 000 entries.
|
||||
private const long DefaultCacheLimit = 1_000;
|
||||
|
||||
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
|
||||
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>The TTL applied to each cached JWT entry. Mirrors Go <c>CacheDirAccResolver.ttl</c>.</summary>
|
||||
public TimeSpan Ttl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new caching directory account resolver.
|
||||
/// <para>
|
||||
/// When <paramref name="limit"/> is zero or negative it defaults to 1 000.
|
||||
/// </para>
|
||||
/// Mirrors Go <c>NewCacheDirAccResolver</c>.
|
||||
/// </summary>
|
||||
/// <param name="path">Directory path for the JWT store.</param>
|
||||
/// <param name="limit">Maximum number of JWTs to cache (0 = 1 000).</param>
|
||||
/// <param name="ttl">Time-to-live for each cached JWT.</param>
|
||||
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
|
||||
public CacheDirAccountResolver(
|
||||
string path,
|
||||
long limit,
|
||||
TimeSpan ttl,
|
||||
params DirResOption[] opts)
|
||||
: base(
|
||||
store: DirJwtStore.NewExpiringDirJwtStore(
|
||||
path,
|
||||
shard: false,
|
||||
create: true,
|
||||
JwtDeleteType.HardDelete,
|
||||
expireCheck: TimeSpan.Zero,
|
||||
limit: limit <= 0 ? DefaultCacheLimit : limit,
|
||||
evictOnLimit: true,
|
||||
ttl: ttl,
|
||||
changeNotification: null),
|
||||
syncInterval: TimeSpan.Zero,
|
||||
fetchTimeout: DefaultFetchTimeout)
|
||||
{
|
||||
Ttl = ttl;
|
||||
Apply(opts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts background system subscriptions for cached JWT update notifications.
|
||||
/// <para>
|
||||
/// TODO (session 12): wire up system subscriptions for account JWT update events
|
||||
/// (cache variant — does not include pack/list/delete handling).
|
||||
/// </para>
|
||||
/// Mirrors Go <c>CacheDirAccResolver.Start</c>.
|
||||
/// </summary>
|
||||
public override void Start(object server)
|
||||
{
|
||||
Server = server;
|
||||
// TODO (session 12): set up system subscriptions for cache-update notifications.
|
||||
}
|
||||
}
|
||||
737
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs
Normal file
737
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs
Normal file
@@ -0,0 +1,737 @@
|
||||
// Copyright 2018-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/accounts.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// AccountLimits — account-based limits
|
||||
// Mirrors Go `limits` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Per-account connection and payload limits.
|
||||
/// Mirrors Go <c>limits</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class AccountLimits
|
||||
{
|
||||
/// <summary>Maximum payload size (-1 = unlimited). Mirrors Go <c>mpay</c>.</summary>
|
||||
public int MaxPayload { get; set; } = -1;
|
||||
|
||||
/// <summary>Maximum subscriptions (-1 = unlimited). Mirrors Go <c>msubs</c>.</summary>
|
||||
public int MaxSubscriptions { get; set; } = -1;
|
||||
|
||||
/// <summary>Maximum connections (-1 = unlimited). Mirrors Go <c>mconns</c>.</summary>
|
||||
public int MaxConnections { get; set; } = -1;
|
||||
|
||||
/// <summary>Maximum leaf nodes (-1 = unlimited). Mirrors Go <c>mleafs</c>.</summary>
|
||||
public int MaxLeafNodes { get; set; } = -1;
|
||||
|
||||
/// <summary>When true, bearer tokens are not allowed. Mirrors Go <c>disallowBearer</c>.</summary>
|
||||
public bool DisallowBearer { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SConns — remote server connection/leafnode counters
|
||||
// Mirrors Go `sconns` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the number of client connections and leaf nodes for a remote server.
|
||||
/// Mirrors Go <c>sconns</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class SConns
|
||||
{
|
||||
/// <summary>Number of client connections from the remote server. Mirrors Go <c>conns</c>.</summary>
|
||||
public int Conns;
|
||||
|
||||
/// <summary>Number of leaf nodes from the remote server. Mirrors Go <c>leafs</c>.</summary>
|
||||
public int Leafs;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServiceRespType — service response type enum
|
||||
// Mirrors Go `ServiceRespType` and its iota constants in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The response type for an exported service.
|
||||
/// Mirrors Go <c>ServiceRespType</c> in server/accounts.go.
|
||||
/// </summary>
|
||||
public enum ServiceRespType : byte
|
||||
{
|
||||
/// <summary>A single response is expected. Default. Mirrors Go <c>Singleton</c>.</summary>
|
||||
Singleton = 0,
|
||||
|
||||
/// <summary>Multiple responses are streamed. Mirrors Go <c>Streamed</c>.</summary>
|
||||
Streamed = 1,
|
||||
|
||||
/// <summary>Responses are sent in chunks. Mirrors Go <c>Chunked</c>.</summary>
|
||||
Chunked = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="ServiceRespType"/>.
|
||||
/// </summary>
|
||||
public static class ServiceRespTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the string representation of the response type.
|
||||
/// Mirrors Go <c>ServiceRespType.String()</c>.
|
||||
/// </summary>
|
||||
public static string ToNatsString(this ServiceRespType rt) => rt switch
|
||||
{
|
||||
ServiceRespType.Singleton => "Singleton",
|
||||
ServiceRespType.Streamed => "Streamed",
|
||||
ServiceRespType.Chunked => "Chunked",
|
||||
_ => "Unknown ServiceResType",
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ExportAuth — export authorization configuration
|
||||
// Mirrors Go `exportAuth` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds configured approvals or a flag indicating that an auth token is
|
||||
/// required for import.
|
||||
/// Mirrors Go <c>exportAuth</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal class ExportAuth
|
||||
{
|
||||
/// <summary>When true, an auth token is required to import this export. Mirrors Go <c>tokenReq</c>.</summary>
|
||||
public bool TokenRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Position in the subject token where the account name appears (for
|
||||
/// public exports that embed the importing account name).
|
||||
/// Mirrors Go <c>accountPos</c>.
|
||||
/// </summary>
|
||||
public uint AccountPosition { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Accounts explicitly approved to import this export.
|
||||
/// Key is the account name. Mirrors Go <c>approved</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, Account>? Approved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Accounts whose activations have been revoked.
|
||||
/// Key is the account name, value is the revocation timestamp (Unix ns).
|
||||
/// Mirrors Go <c>actsRevoked</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, long>? ActivationsRevoked { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamExport — exported stream descriptor
|
||||
// Mirrors Go `streamExport` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Describes a stream exported by an account.
|
||||
/// Mirrors Go <c>streamExport</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class StreamExport : ExportAuth
|
||||
{
|
||||
// No additional fields beyond ExportAuth for now.
|
||||
// Full implementation in session 11 (accounts.go).
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InternalServiceLatency — service latency tracking configuration
|
||||
// Mirrors Go `serviceLatency` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for service latency tracking on an exported service.
|
||||
/// Mirrors Go <c>serviceLatency</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class InternalServiceLatency
|
||||
{
|
||||
/// <summary>
|
||||
/// Sampling percentage (1–100), or 0 to indicate triggered by header.
|
||||
/// Mirrors Go <c>sampling int8</c>.
|
||||
/// </summary>
|
||||
public int Sampling { get; set; }
|
||||
|
||||
/// <summary>Subject to publish latency metrics to. Mirrors Go <c>subject</c>.</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServiceExportEntry — exported service descriptor
|
||||
// Mirrors Go `serviceExport` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Describes a service exported by an account with additional configuration
|
||||
/// for response type, latency tracking, and timers.
|
||||
/// Mirrors Go <c>serviceExport</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class ServiceExportEntry : ExportAuth
|
||||
{
|
||||
/// <summary>Account that owns this export. Mirrors Go <c>acc</c>.</summary>
|
||||
public Account? Account { get; set; }
|
||||
|
||||
/// <summary>Response type (Singleton, Streamed, Chunked). Mirrors Go <c>respType</c>.</summary>
|
||||
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
|
||||
|
||||
/// <summary>Latency tracking configuration, or null if disabled. Mirrors Go <c>latency</c>.</summary>
|
||||
public InternalServiceLatency? Latency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timer used to collect response-latency measurements.
|
||||
/// Mirrors Go <c>rtmr *time.Timer</c>.
|
||||
/// </summary>
|
||||
public Timer? ResponseTimer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold duration for service responses.
|
||||
/// Mirrors Go <c>respThresh time.Duration</c>.
|
||||
/// </summary>
|
||||
public TimeSpan ResponseThreshold { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, tracing is allowed past the account boundary for this export.
|
||||
/// Mirrors Go <c>atrc</c> (allow_trace).
|
||||
/// </summary>
|
||||
public bool AllowTrace { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ExportMap — tracks exported streams and services for an account
|
||||
// Mirrors Go `exportMap` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks all stream exports, service exports, and response mappings for an account.
|
||||
/// Mirrors Go <c>exportMap</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class ExportMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Exported streams keyed by subject pattern.
|
||||
/// Mirrors Go <c>streams map[string]*streamExport</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, StreamExport>? Streams { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exported services keyed by subject pattern.
|
||||
/// Mirrors Go <c>services map[string]*serviceExport</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, ServiceExportEntry>? Services { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// In-flight response service imports keyed by reply subject.
|
||||
/// Mirrors Go <c>responses map[string]*serviceImport</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, ServiceImportEntry>? Responses { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ImportMap — tracks imported streams and services for an account
|
||||
// Mirrors Go `importMap` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks all stream imports, service imports, and reverse-response maps.
|
||||
/// Mirrors Go <c>importMap</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class ImportMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Imported streams (ordered list).
|
||||
/// Mirrors Go <c>streams []*streamImport</c>.
|
||||
/// </summary>
|
||||
public List<StreamImportEntry>? Streams { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Imported services keyed by subject pattern; each key may have
|
||||
/// multiple import entries (e.g. fan-out imports).
|
||||
/// Mirrors Go <c>services map[string][]*serviceImport</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, List<ServiceImportEntry>>? Services { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-response map used to clean up singleton service imports.
|
||||
/// Mirrors Go <c>rrMap map[string][]*serviceRespEntry</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, List<ServiceRespEntry>>? ReverseResponseMap { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamImportEntry — an imported stream mapping
|
||||
// Mirrors Go `streamImport` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An imported stream from another account, with optional subject remapping.
|
||||
/// Mirrors Go <c>streamImport</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class StreamImportEntry
|
||||
{
|
||||
/// <summary>Account providing the stream. Mirrors Go <c>acc</c>.</summary>
|
||||
public Account? Account { get; set; }
|
||||
|
||||
/// <summary>Source subject on the exporting account. Mirrors Go <c>from</c>.</summary>
|
||||
public string From { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Destination subject on the importing account. Mirrors Go <c>to</c>.</summary>
|
||||
public string To { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Subject transform applied to the source subject.
|
||||
/// Mirrors Go <c>tr *subjectTransform</c>.
|
||||
/// Stubbed as <see cref="ISubjectTransformer"/> until the transform
|
||||
/// engine is wired in.
|
||||
/// </summary>
|
||||
public ISubjectTransformer? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reverse transform for reply subjects.
|
||||
/// Mirrors Go <c>rtr *subjectTransform</c>.
|
||||
/// </summary>
|
||||
public ISubjectTransformer? ReverseTransform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JWT import claim that authorized this import.
|
||||
/// Mirrors Go <c>claim *jwt.Import</c>.
|
||||
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
|
||||
/// </summary>
|
||||
public object? Claim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, use the published subject instead of <see cref="To"/>.
|
||||
/// Mirrors Go <c>usePub</c>.
|
||||
/// </summary>
|
||||
public bool UsePublishedSubject { get; set; }
|
||||
|
||||
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
|
||||
public bool Invalid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, tracing is allowed past the account boundary.
|
||||
/// Mirrors Go <c>atrc</c> (allow_trace).
|
||||
/// </summary>
|
||||
public bool AllowTrace { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServiceImportEntry — an imported service mapping
|
||||
// Mirrors Go `serviceImport` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An imported service from another account, with response routing and
|
||||
/// latency tracking state.
|
||||
/// Mirrors Go <c>serviceImport</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class ServiceImportEntry
|
||||
{
|
||||
/// <summary>Account providing the service. Mirrors Go <c>acc</c>.</summary>
|
||||
public Account? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JWT import claim that authorized this import.
|
||||
/// Mirrors Go <c>claim *jwt.Import</c>.
|
||||
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
|
||||
/// </summary>
|
||||
public object? Claim { get; set; }
|
||||
|
||||
/// <summary>Parent service export entry. Mirrors Go <c>se *serviceExport</c>.</summary>
|
||||
public ServiceExportEntry? ServiceExport { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subscription ID byte slice for cleanup.
|
||||
/// Mirrors Go <c>sid []byte</c>.
|
||||
/// </summary>
|
||||
public byte[]? SubscriptionId { get; set; }
|
||||
|
||||
/// <summary>Source subject on the importing account. Mirrors Go <c>from</c>.</summary>
|
||||
public string From { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Destination subject on the exporting account. Mirrors Go <c>to</c>.</summary>
|
||||
public string To { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Subject transform applied when routing requests.
|
||||
/// Mirrors Go <c>tr *subjectTransform</c>.
|
||||
/// Stubbed as <see cref="ISubjectTransformer"/> until transform engine is wired in.
|
||||
/// </summary>
|
||||
public ISubjectTransformer? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp (Unix nanoseconds) when the import request was created.
|
||||
/// Used for latency tracking. Mirrors Go <c>ts int64</c>.
|
||||
/// </summary>
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
/// <summary>Response type for this service import. Mirrors Go <c>rt ServiceRespType</c>.</summary>
|
||||
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
|
||||
|
||||
/// <summary>Latency tracking configuration. Mirrors Go <c>latency *serviceLatency</c>.</summary>
|
||||
public InternalServiceLatency? Latency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// First-leg latency measurement (requestor side).
|
||||
/// Mirrors Go <c>m1 *ServiceLatency</c>.
|
||||
/// </summary>
|
||||
public ServiceLatency? M1 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Client connection that sent the original request.
|
||||
/// Mirrors Go <c>rc *client</c>.
|
||||
/// </summary>
|
||||
public ClientConnection? RequestingClient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, use the published subject instead of <see cref="To"/>.
|
||||
/// Mirrors Go <c>usePub</c>.
|
||||
/// </summary>
|
||||
public bool UsePublishedSubject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, this import entry represents a pending response rather
|
||||
/// than an originating request.
|
||||
/// Mirrors Go <c>response</c>.
|
||||
/// </summary>
|
||||
public bool IsResponse { get; set; }
|
||||
|
||||
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
|
||||
public bool Invalid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the requestor's <see cref="ClientInfo"/> is shared with
|
||||
/// the responder. Mirrors Go <c>share</c>.
|
||||
/// </summary>
|
||||
public bool Share { get; set; }
|
||||
|
||||
/// <summary>Whether latency tracking is active. Mirrors Go <c>tracking</c>.</summary>
|
||||
public bool Tracking { get; set; }
|
||||
|
||||
/// <summary>Whether a response was delivered to the requestor. Mirrors Go <c>didDeliver</c>.</summary>
|
||||
public bool DidDeliver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, tracing is allowed past the account boundary (inherited
|
||||
/// from the service export). Mirrors Go <c>atrc</c>.
|
||||
/// </summary>
|
||||
public bool AllowTrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Headers from the original request, used when latency is triggered by
|
||||
/// a header. Mirrors Go <c>trackingHdr http.Header</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, string[]>? TrackingHeader { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServiceRespEntry — reverse-response map entry
|
||||
// Mirrors Go `serviceRespEntry` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records a service import mapping for reverse-response-map cleanup.
|
||||
/// Mirrors Go <c>serviceRespEntry</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class ServiceRespEntry
|
||||
{
|
||||
/// <summary>Account that owns the service import. Mirrors Go <c>acc</c>.</summary>
|
||||
public Account? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The mapped subscription subject used for the response.
|
||||
/// Mirrors Go <c>msub</c>.
|
||||
/// </summary>
|
||||
public string MappedSubject { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MapDest — public API for weighted subject mappings
|
||||
// Mirrors Go `MapDest` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Describes a weighted mapping destination for published subjects.
|
||||
/// Mirrors Go <c>MapDest</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
public sealed class MapDest
|
||||
{
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("weight")]
|
||||
public byte Weight { get; set; }
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
public string Cluster { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MapDest"/> with the given subject and weight.
|
||||
/// Mirrors Go <c>NewMapDest</c>.
|
||||
/// </summary>
|
||||
public static MapDest New(string subject, byte weight) =>
|
||||
new() { Subject = subject, Weight = weight };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Destination — internal weighted mapped destination
|
||||
// Mirrors Go `destination` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Internal representation of a weighted mapped destination, holding a
|
||||
/// transform and a weight.
|
||||
/// Mirrors Go <c>destination</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class Destination
|
||||
{
|
||||
/// <summary>
|
||||
/// Transform that converts the source subject to the destination subject.
|
||||
/// Mirrors Go <c>tr *subjectTransform</c>.
|
||||
/// </summary>
|
||||
public ISubjectTransformer? Transform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative weight (0–100). Mirrors Go <c>weight uint8</c>.
|
||||
/// </summary>
|
||||
public byte Weight { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SubjectMapping — internal subject mapping entry
|
||||
// Mirrors Go `mapping` struct in server/accounts.go.
|
||||
// Renamed from `mapping` to avoid collision with the C# keyword context.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// An internal entry describing how a source subject is remapped to one or
|
||||
/// more weighted destinations, optionally scoped to specific clusters.
|
||||
/// Mirrors Go <c>mapping</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class SubjectMapping
|
||||
{
|
||||
/// <summary>Source subject pattern. Mirrors Go <c>src</c>.</summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source contains wildcards.
|
||||
/// Mirrors Go <c>wc</c>.
|
||||
/// </summary>
|
||||
public bool HasWildcard { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Weighted destinations with no cluster scope.
|
||||
/// Mirrors Go <c>dests []*destination</c>.
|
||||
/// </summary>
|
||||
public List<Destination> Destinations { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Per-cluster weighted destinations.
|
||||
/// Key is the cluster name. Mirrors Go <c>cdests map[string][]*destination</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, List<Destination>>? ClusterDestinations { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TypedEvent — base for server advisory events
|
||||
// Mirrors Go `TypedEvent` struct in server/events.go.
|
||||
// Included here because ServiceLatency embeds it.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Base fields for a NATS typed event or advisory.
|
||||
/// Mirrors Go <c>TypedEvent</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public class TypedEvent
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTime Time { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServiceLatency — public latency measurement event
|
||||
// Mirrors Go `ServiceLatency` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The JSON message published to a latency-tracking subject when a service
|
||||
/// request completes. Includes requestor and responder timing breakdowns.
|
||||
/// Mirrors Go <c>ServiceLatency</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
public sealed class ServiceLatency : TypedEvent
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Error { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("requestor")]
|
||||
public ClientInfo? Requestor { get; set; }
|
||||
|
||||
[JsonPropertyName("responder")]
|
||||
public ClientInfo? Responder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Headers from the original request that triggered latency measurement.
|
||||
/// Mirrors Go <c>RequestHeader http.Header</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("header")]
|
||||
public Dictionary<string, string[]>? RequestHeader { get; set; }
|
||||
|
||||
[JsonPropertyName("start")]
|
||||
public DateTime RequestStart { get; set; }
|
||||
|
||||
/// <summary>Mirrors Go <c>ServiceLatency time.Duration</c> (nanoseconds).</summary>
|
||||
[JsonPropertyName("service")]
|
||||
public TimeSpan ServiceLatencyDuration { get; set; }
|
||||
|
||||
/// <summary>Mirrors Go <c>SystemLatency time.Duration</c> (nanoseconds).</summary>
|
||||
[JsonPropertyName("system")]
|
||||
public TimeSpan SystemLatency { get; set; }
|
||||
|
||||
/// <summary>Mirrors Go <c>TotalLatency time.Duration</c> (nanoseconds).</summary>
|
||||
[JsonPropertyName("total")]
|
||||
public TimeSpan TotalLatency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the sum of requestor RTT, responder RTT, and system latency.
|
||||
/// Mirrors Go <c>ServiceLatency.NATSTotalTime()</c>.
|
||||
/// </summary>
|
||||
public TimeSpan NATSTotalTime()
|
||||
{
|
||||
var requestorRtt = Requestor?.Rtt ?? TimeSpan.Zero;
|
||||
var responderRtt = Responder?.Rtt ?? TimeSpan.Zero;
|
||||
return requestorRtt + responderRtt + SystemLatency;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RemoteLatency — cross-server latency transport message
|
||||
// Mirrors Go `remoteLatency` struct in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Used to transport a responder-side latency measurement to the
|
||||
/// requestor's server so the two halves can be merged.
|
||||
/// Mirrors Go <c>remoteLatency</c> struct in server/accounts.go.
|
||||
/// </summary>
|
||||
internal sealed class RemoteLatency
|
||||
{
|
||||
[JsonPropertyName("account")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("req_id")]
|
||||
public string RequestId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("m2")]
|
||||
public ServiceLatency M2 { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Private: response latency threshold used when deciding whether to
|
||||
/// send the remote measurement.
|
||||
/// Mirrors Go <c>respThresh time.Duration</c>.
|
||||
/// </summary>
|
||||
public TimeSpan ResponseThreshold { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RsiReason — reason for removing a response service import
|
||||
// Mirrors Go `rsiReason` and its iota constants in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The reason a response service import entry is being removed.
|
||||
/// Mirrors Go <c>rsiReason</c> and its iota constants in server/accounts.go.
|
||||
/// </summary>
|
||||
internal enum RsiReason
|
||||
{
|
||||
/// <summary>Normal completion. Mirrors Go <c>rsiOk</c>.</summary>
|
||||
Ok = 0,
|
||||
|
||||
/// <summary>Response was never delivered. Mirrors Go <c>rsiNoDelivery</c>.</summary>
|
||||
NoDelivery = 1,
|
||||
|
||||
/// <summary>Response timed out. Mirrors Go <c>rsiTimeout</c>.</summary>
|
||||
Timeout = 2,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account-level constants
|
||||
// Mirrors the const blocks in server/accounts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Constants related to account route-pool indexing and search depth.
|
||||
/// </summary>
|
||||
internal static class AccountConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Sentinel value indicating the account has a dedicated route connection.
|
||||
/// Mirrors Go <c>accDedicatedRoute = -1</c>.
|
||||
/// </summary>
|
||||
public const int DedicatedRoute = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel value indicating the account is in the process of transitioning
|
||||
/// to a dedicated route.
|
||||
/// Mirrors Go <c>accTransitioningToDedicatedRoute = -2</c>.
|
||||
/// </summary>
|
||||
public const int TransitioningToDedicatedRoute = -2;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for account cycle detection when following import chains.
|
||||
/// Mirrors Go <c>MaxAccountCycleSearchDepth = 1024</c>.
|
||||
/// </summary>
|
||||
public const int MaxCycleSearchDepth = 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known header names and event type identifiers used by the account
|
||||
/// service-latency and client-info subsystems.
|
||||
/// </summary>
|
||||
public static class AccountEventConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Header name used to pass client metadata into a service request.
|
||||
/// Mirrors Go <c>ClientInfoHdr = "Nats-Request-Info"</c>.
|
||||
/// </summary>
|
||||
public const string ClientInfoHeader = "Nats-Request-Info";
|
||||
|
||||
/// <summary>
|
||||
/// The default threshold (in nanoseconds, as a <see cref="TimeSpan"/>) below
|
||||
/// which a subscription-limit report is suppressed.
|
||||
/// Mirrors Go <c>defaultMaxSubLimitReportThreshold = int64(2 * time.Second)</c>.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultMaxSubLimitReportThreshold = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// NATS event type identifier for <see cref="ServiceLatency"/> messages.
|
||||
/// Mirrors Go <c>ServiceLatencyType = "io.nats.server.metric.v1.service_latency"</c>.
|
||||
/// </summary>
|
||||
public const string ServiceLatencyType = "io.nats.server.metric.v1.service_latency";
|
||||
}
|
||||
1374
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
Normal file
1374
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
Normal file
File diff suppressed because it is too large
Load Diff
93
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs
Normal file
93
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright 2022-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/auth_callout.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// External auth callout support.
|
||||
/// Mirrors Go <c>auth_callout.go</c>.
|
||||
/// </summary>
|
||||
internal static class AuthCallout
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an auth request to the configured callout account and awaits
|
||||
/// a signed JWT response that authorises or rejects the connecting client.
|
||||
/// Mirrors Go <c>processClientOrLeafCallout</c> in auth_callout.go.
|
||||
/// </summary>
|
||||
public static bool ProcessClientOrLeafCallout(NatsServer server, ClientConnection c, ServerOptions opts)
|
||||
{
|
||||
// Full implementation requires internal NATS pub/sub with async request/reply.
|
||||
// This is intentionally left as a stub until the internal NATS connection layer is available.
|
||||
throw new NotImplementedException(
|
||||
"Auth callout requires internal NATS pub/sub — implement when connection layer is available.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates an authorization request payload with client connection info.
|
||||
/// Mirrors Go <c>client.fillClientInfo</c> in auth_callout.go.
|
||||
/// </summary>
|
||||
public static void FillClientInfo(AuthorizationRequest req, ClientConnection c)
|
||||
{
|
||||
req.ClientInfoObj = new AuthorizationClientInfo
|
||||
{
|
||||
Host = c.Host,
|
||||
Id = c.Cid,
|
||||
Kind = c.Kind.ToString().ToLowerInvariant(),
|
||||
Type = "client",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates an authorization request payload with connect options.
|
||||
/// Mirrors Go <c>client.fillConnectOpts</c> in auth_callout.go.
|
||||
/// </summary>
|
||||
public static void FillConnectOpts(AuthorizationRequest req, ClientConnection c)
|
||||
{
|
||||
req.ConnectOptions = new AuthorizationConnectOpts
|
||||
{
|
||||
Username = c.GetUsername(),
|
||||
Password = c.GetPassword(),
|
||||
AuthToken = c.GetAuthToken(),
|
||||
Nkey = c.GetNkey(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Authorization request sent to auth callout service.</summary>
|
||||
public sealed class AuthorizationRequest
|
||||
{
|
||||
public string ServerId { get; set; } = string.Empty;
|
||||
public string UserNkey { get; set; } = string.Empty;
|
||||
public AuthorizationClientInfo? ClientInfoObj { get; set; }
|
||||
public AuthorizationConnectOpts? ConnectOptions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Client info portion of an authorization request.</summary>
|
||||
public sealed class AuthorizationClientInfo
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public ulong Id { get; set; }
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Connect options portion of an authorization request.</summary>
|
||||
public sealed class AuthorizationConnectOpts
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string AuthToken { get; set; } = string.Empty;
|
||||
public string Nkey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
@@ -270,4 +271,104 @@ public static partial class AuthHandler
|
||||
{
|
||||
buf.Fill((byte)'x');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the closed-client state for an auth error.
|
||||
/// Mirrors Go <c>getAuthErrClosedState</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public static ClosedState GetAuthErrClosedState(Exception? err)
|
||||
{
|
||||
return err switch
|
||||
{
|
||||
AuthProxyNotTrustedException => ClosedState.ProxyNotTrusted,
|
||||
AuthProxyRequiredException => ClosedState.ProxyRequired,
|
||||
_ => ClosedState.AuthenticationViolation,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that proxy protocol configuration is consistent.
|
||||
/// If <see cref="ServerOptions.ProxyRequired"/> is set, <see cref="ServerOptions.ProxyProtocol"/> must also be enabled.
|
||||
/// Note: Full NKey-format validation of trusted proxy keys is deferred until proxy auth is fully implemented.
|
||||
/// Partially mirrors Go <c>validateProxies</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public static Exception? ValidateProxies(ServerOptions opts)
|
||||
{
|
||||
if (opts.ProxyRequired && !opts.ProxyProtocol)
|
||||
return new InvalidOperationException("proxy_required requires proxy_protocol to be enabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the DC= attribute values from a certificate's distinguished name.
|
||||
/// Mirrors Go <c>getTLSAuthDCs</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public static string GetTlsAuthDcs(System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
|
||||
{
|
||||
var subject = cert.Subject;
|
||||
var dcs = new System.Text.StringBuilder();
|
||||
foreach (var part in subject.Split(','))
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (trimmed.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (dcs.Length > 0) dcs.Append('.');
|
||||
dcs.Append(trimmed[3..]);
|
||||
}
|
||||
}
|
||||
return dcs.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a client's TLS certificate subject matches using the provided matcher function.
|
||||
/// Mirrors Go <c>checkClientTLSCertSubject</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public static bool CheckClientTlsCertSubject(
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
|
||||
Func<string, bool> matcher)
|
||||
{
|
||||
if (cert == null) return false;
|
||||
return matcher(cert.Subject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands template variables ({{account}}, {{tag.*}}) in JWT user permission limits.
|
||||
/// Mirrors Go <c>processUserPermissionsTemplate</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public static (Permissions Result, Exception? Error) ProcessUserPermissionsTemplate(
|
||||
Permissions lim,
|
||||
string accountName,
|
||||
Dictionary<string, string>? tags)
|
||||
{
|
||||
ExpandSubjectList(lim.Publish?.Allow, accountName, tags);
|
||||
ExpandSubjectList(lim.Publish?.Deny, accountName, tags);
|
||||
ExpandSubjectList(lim.Subscribe?.Allow, accountName, tags);
|
||||
ExpandSubjectList(lim.Subscribe?.Deny, accountName, tags);
|
||||
return (lim, null);
|
||||
}
|
||||
|
||||
private static readonly Regex TemplateVar =
|
||||
new(@"\{\{(\w+(?:\.\w+)*)\}\}", RegexOptions.Compiled);
|
||||
|
||||
private static void ExpandSubjectList(List<string>? subjects, string accountName, Dictionary<string, string>? tags)
|
||||
{
|
||||
if (subjects == null) return;
|
||||
for (var i = 0; i < subjects.Count; i++)
|
||||
subjects[i] = ExpandTemplate(subjects[i], accountName, tags);
|
||||
}
|
||||
|
||||
private static string ExpandTemplate(string subject, string accountName, Dictionary<string, string>? tags)
|
||||
{
|
||||
return TemplateVar.Replace(subject, m =>
|
||||
{
|
||||
var key = m.Groups[1].Value;
|
||||
if (key.Equals("account", StringComparison.OrdinalIgnoreCase)) return accountName;
|
||||
if (key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase) && tags != null)
|
||||
{
|
||||
var tagKey = key[4..];
|
||||
return tags.TryGetValue(tagKey, out var v) ? v : m.Value;
|
||||
}
|
||||
return m.Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//
|
||||
// Adapted from server/auth.go (type definitions) in the NATS server Go source.
|
||||
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
@@ -166,11 +168,23 @@ public class RoutePermissions
|
||||
public SubjectPermission? Export { get; set; }
|
||||
}
|
||||
|
||||
// Account stub removed — full implementation is in Accounts/Account.cs
|
||||
// in the ZB.MOM.NatsNet.Server namespace.
|
||||
|
||||
/// <summary>
|
||||
/// Stub for Account type. Full implementation in later sessions.
|
||||
/// Mirrors Go <c>Account</c> struct.
|
||||
/// Sentinel exception representing a proxy-auth "not trusted" error.
|
||||
/// Mirrors Go <c>ErrAuthProxyNotTrusted</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public class Account
|
||||
public sealed class AuthProxyNotTrustedException : InvalidOperationException
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public AuthProxyNotTrustedException() : base("proxy not trusted") { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel exception representing a proxy-auth "required" error.
|
||||
/// Mirrors Go <c>ErrAuthProxyRequired</c> in server/auth.go.
|
||||
/// </summary>
|
||||
public sealed class AuthProxyRequiredException : InvalidOperationException
|
||||
{
|
||||
public AuthProxyRequiredException() : base("proxy required") { }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// Adapted from server/jwt.go in the NATS server Go source.
|
||||
|
||||
using System.Net;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
@@ -30,15 +31,6 @@ public static class JwtProcessor
|
||||
/// </summary>
|
||||
public const string JwtPrefix = "eyJ";
|
||||
|
||||
/// <summary>
|
||||
/// Wipes a byte slice by filling with 'x', for clearing nkey seed data.
|
||||
/// Mirrors Go <c>wipeSlice</c>.
|
||||
/// </summary>
|
||||
public static void WipeSlice(Span<byte> buf)
|
||||
{
|
||||
buf.Fill((byte)'x');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the given IP host address is allowed by the user claims source CIDRs.
|
||||
/// Returns true if the host is within any of the allowed CIDRs, or if no CIDRs are specified.
|
||||
@@ -179,6 +171,58 @@ public static class JwtProcessor
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an operator JWT from a file path. Returns (claims, error).
|
||||
/// Mirrors Go <c>ReadOperatorJWT</c> in server/jwt.go.
|
||||
/// </summary>
|
||||
public static (object? Claims, Exception? Error) ReadOperatorJwt(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return (null, new ArgumentException("operator JWT path is empty"));
|
||||
|
||||
string jwtString;
|
||||
try
|
||||
{
|
||||
jwtString = File.ReadAllText(path, System.Text.Encoding.ASCII).Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (null, new IOException($"error reading operator JWT file: {ex.Message}", ex));
|
||||
}
|
||||
return ReadOperatorJwtInternal(jwtString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an operator JWT string. Returns (claims, error).
|
||||
/// Mirrors Go <c>readOperatorJWT</c> in server/jwt.go.
|
||||
/// </summary>
|
||||
public static (object? Claims, Exception? Error) ReadOperatorJwtInternal(string jwtString)
|
||||
{
|
||||
if (string.IsNullOrEmpty(jwtString))
|
||||
return (null, new ArgumentException("operator JWT string is empty"));
|
||||
if (!jwtString.StartsWith(JwtPrefix, StringComparison.Ordinal))
|
||||
return (null, new FormatException($"operator JWT does not start with expected prefix '{JwtPrefix}'"));
|
||||
|
||||
// Full NATS JWT parsing would require a dedicated JWT library.
|
||||
// At this level, we validate the prefix and structure.
|
||||
return (null, new FormatException("operator JWT parsing not fully implemented — requires NATS JWT library"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the trusted operator JWTs in options.
|
||||
/// Mirrors Go <c>validateTrustedOperators</c> in server/jwt.go.
|
||||
/// </summary>
|
||||
public static Exception? ValidateTrustedOperators(ServerOptions opts)
|
||||
{
|
||||
if (opts.TrustedOperators == null || opts.TrustedOperators.Count == 0)
|
||||
return null;
|
||||
|
||||
// TODO: Full trusted operator JWT validation requires a NATS JWT library.
|
||||
// Each operator JWT should be decoded and its signing key chain verified.
|
||||
// For now, we accept any non-empty operator list and validate at connect time.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
225
dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs
Normal file
225
dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
// Copyright 2021-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/ocsp.go, server/ocsp_peer.go, server/ocsp_responsecache.go
|
||||
// in the NATS server Go source.
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
||||
|
||||
/// <summary>
|
||||
/// Controls how OCSP stapling behaves for a TLS certificate.
|
||||
/// Mirrors Go <c>OCSPMode uint8</c> in server/ocsp.go.
|
||||
/// </summary>
|
||||
public enum OcspMode : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Staple only if the "status_request" OID is present in the certificate.
|
||||
/// Mirrors Go <c>OCSPModeAuto</c>.
|
||||
/// </summary>
|
||||
Auto = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Must staple — honors the Must-Staple flag and shuts down on revocation.
|
||||
/// Mirrors Go <c>OCSPModeMust</c>.
|
||||
/// </summary>
|
||||
MustStaple = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Always obtain OCSP status, regardless of certificate flags.
|
||||
/// Mirrors Go <c>OCSPModeAlways</c>.
|
||||
/// </summary>
|
||||
Always = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Never check OCSP, even if the certificate has the Must-Staple flag.
|
||||
/// Mirrors Go <c>OCSPModeNever</c>.
|
||||
/// </summary>
|
||||
Never = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds a cached OCSP staple response and its expiry information.
|
||||
/// </summary>
|
||||
internal sealed class OcspStaple
|
||||
{
|
||||
/// <summary>The raw DER-encoded OCSP response bytes.</summary>
|
||||
public byte[]? Response { get; set; }
|
||||
|
||||
/// <summary>When the OCSP response next needs to be refreshed.</summary>
|
||||
public DateTime NextUpdate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates OCSP stapling for a single TLS certificate.
|
||||
/// Monitors certificate validity and refreshes the staple on a background timer.
|
||||
/// Mirrors Go <c>OCSPMonitor</c> struct in server/ocsp.go.
|
||||
/// Replaces the stub in NatsServerTypes.cs.
|
||||
/// </summary>
|
||||
internal sealed class OcspMonitor
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
private Timer? _timer;
|
||||
private readonly OcspStaple _staple = new();
|
||||
|
||||
/// <summary>Path to the TLS certificate file being monitored.</summary>
|
||||
public string? CertFile { get; set; }
|
||||
|
||||
/// <summary>Path to the CA certificate file used to verify OCSP responses.</summary>
|
||||
public string? CaFile { get; set; }
|
||||
|
||||
/// <summary>Path to a persisted OCSP staple file (optional).</summary>
|
||||
public string? OcspStapleFile { get; set; }
|
||||
|
||||
/// <summary>The OCSP stapling mode for this monitor.</summary>
|
||||
public OcspMode Mode { get; set; }
|
||||
|
||||
/// <summary>How often to check for a fresh OCSP response.</summary>
|
||||
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>The owning server instance.</summary>
|
||||
public NatsServer? Server { get; set; }
|
||||
|
||||
/// <summary>The synchronisation lock for this monitor's mutable state.</summary>
|
||||
public Lock Mu => _mu;
|
||||
|
||||
/// <summary>Starts the background OCSP refresh timer.</summary>
|
||||
public void Start()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_timer != null)
|
||||
return;
|
||||
|
||||
_timer = new Timer(_ =>
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(OcspStapleFile) && File.Exists(OcspStapleFile))
|
||||
_staple.Response = File.ReadAllBytes(OcspStapleFile);
|
||||
_staple.NextUpdate = DateTime.UtcNow + CheckInterval;
|
||||
}
|
||||
}, null, TimeSpan.Zero, CheckInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops the background OCSP refresh timer.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current cached OCSP staple bytes, or <c>null</c> if none.</summary>
|
||||
public byte[]? GetStaple()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
return _staple.Response == null ? null : [.. _staple.Response];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for caching raw OCSP response bytes keyed by certificate fingerprint.
|
||||
/// Mirrors Go <c>OCSPResponseCache</c> interface in server/ocsp_responsecache.go.
|
||||
/// Replaces the stub in NatsServerTypes.cs.
|
||||
/// </summary>
|
||||
public interface IOcspResponseCache
|
||||
{
|
||||
/// <summary>Returns the cached OCSP response for <paramref name="key"/>, or <c>null</c>.</summary>
|
||||
byte[]? Get(string key);
|
||||
|
||||
/// <summary>Stores an OCSP response under <paramref name="key"/>.</summary>
|
||||
void Put(string key, byte[] response);
|
||||
|
||||
/// <summary>Removes the cached entry for <paramref name="key"/>.</summary>
|
||||
void Remove(string key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A no-op OCSP cache that never stores anything.
|
||||
/// Mirrors Go <c>NoOpCache</c> in server/ocsp_responsecache.go.
|
||||
/// </summary>
|
||||
internal sealed class NoOpCache : IOcspResponseCache
|
||||
{
|
||||
public byte[]? Get(string key) => null;
|
||||
public void Put(string key, byte[] response) { }
|
||||
public void Remove(string key) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OCSP cache backed by a local directory on disk.
|
||||
/// Mirrors Go <c>LocalCache</c> in server/ocsp_responsecache.go.
|
||||
/// Full implementation is deferred to session 23.
|
||||
/// </summary>
|
||||
internal sealed class LocalDirCache : IOcspResponseCache
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public LocalDirCache(string dir)
|
||||
{
|
||||
_dir = dir;
|
||||
}
|
||||
|
||||
public byte[]? Get(string key)
|
||||
{
|
||||
var file = CacheFilePath(key);
|
||||
if (!File.Exists(file))
|
||||
return null;
|
||||
return File.ReadAllBytes(file);
|
||||
}
|
||||
|
||||
public void Put(string key, byte[] response)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllBytes(CacheFilePath(key), response);
|
||||
}
|
||||
|
||||
public void Remove(string key)
|
||||
{
|
||||
var file = CacheFilePath(key);
|
||||
if (File.Exists(file))
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
private string CacheFilePath(string key)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
var file = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return Path.Combine(_dir, $"{file}.ocsp");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for the OCSP peer certificate rejection advisory event.
|
||||
/// Mirrors Go <c>OCSPPeerRejectEventMsg</c> fields in server/events.go
|
||||
/// and the OCSP peer reject logic in server/ocsp_peer.go.
|
||||
/// </summary>
|
||||
public sealed class OcspPeerRejectInfo
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("peer")]
|
||||
public string Peer { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ using System.Net.Sockets;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
@@ -166,6 +167,7 @@ public sealed partial class ClientConnection
|
||||
private Timer? _atmr; // auth timer
|
||||
private Timer? _pingTimer;
|
||||
private Timer? _tlsTo;
|
||||
private Timer? _expTimer;
|
||||
|
||||
// Ping state.
|
||||
private int _pingOut; // outstanding pings
|
||||
@@ -655,12 +657,25 @@ public sealed partial class ClientConnection
|
||||
|
||||
internal void SetExpirationTimer(TimeSpan d)
|
||||
{
|
||||
// TODO: Implement when Server is available (session 09).
|
||||
lock (_mu)
|
||||
{
|
||||
SetExpirationTimerUnlocked(d);
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetExpirationTimerUnlocked(TimeSpan d)
|
||||
{
|
||||
// TODO: Implement when Server is available (session 09).
|
||||
var prev = Interlocked.Exchange(ref _expTimer, null);
|
||||
prev?.Dispose();
|
||||
|
||||
if (d <= TimeSpan.Zero)
|
||||
{
|
||||
ClaimExpiration();
|
||||
return;
|
||||
}
|
||||
|
||||
Expires = DateTime.UtcNow + d;
|
||||
_expTimer = new Timer(_ => ClaimExpiration(), null, d, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -842,13 +857,60 @@ public sealed partial class ClientConnection
|
||||
internal void SetAuthError(Exception err) { lock (_mu) { AuthErr = err; } }
|
||||
internal Exception? GetAuthError() { lock (_mu) { return AuthErr; } }
|
||||
|
||||
// Auth credential accessors (used by NatsServer.Auth.cs)
|
||||
internal string GetAuthToken() { lock (_mu) { return Opts.Token; } }
|
||||
internal string GetNkey() { lock (_mu) { return Opts.Nkey; } }
|
||||
internal string GetNkeySig() { lock (_mu) { return Opts.Sig; } }
|
||||
internal string GetUsername() { lock (_mu) { return Opts.Username; } }
|
||||
internal string GetPassword() { lock (_mu) { return Opts.Password; } }
|
||||
|
||||
internal X509Certificate2? GetTlsCertificate()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_nc is SslStream ssl)
|
||||
{
|
||||
var cert = ssl.RemoteCertificate;
|
||||
if (cert is X509Certificate2 cert2) return cert2;
|
||||
if (cert != null) return new X509Certificate2(cert);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetAccount(INatsAccount? acc)
|
||||
{
|
||||
lock (_mu) { Account = acc; }
|
||||
}
|
||||
|
||||
internal void SetAccount(Account? acc) => SetAccount(acc as INatsAccount);
|
||||
|
||||
internal void SetPermissions(Auth.Permissions? perms)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (perms != null)
|
||||
Perms = BuildPermissions(perms);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Timer helpers (features 523-531)
|
||||
// =========================================================================
|
||||
|
||||
internal void SetPingTimer()
|
||||
{
|
||||
// TODO: Implement when Server is available.
|
||||
var interval = Server?.Options.PingInterval ?? TimeSpan.FromMinutes(2);
|
||||
if (interval <= TimeSpan.Zero)
|
||||
return;
|
||||
|
||||
ClearPingTimer();
|
||||
_pingTimer = new Timer(_ =>
|
||||
{
|
||||
if (IsClosed())
|
||||
return;
|
||||
SendPing();
|
||||
}, null, interval, interval);
|
||||
}
|
||||
|
||||
internal void ClearPingTimer()
|
||||
@@ -865,7 +927,10 @@ public sealed partial class ClientConnection
|
||||
|
||||
internal void SetAuthTimer()
|
||||
{
|
||||
// TODO: Implement when Server is available.
|
||||
var timeout = Server?.Options.AuthTimeout ?? 0;
|
||||
if (timeout <= 0)
|
||||
return;
|
||||
SetAuthTimer(TimeSpan.FromSeconds(timeout));
|
||||
}
|
||||
|
||||
internal void ClearAuthTimer()
|
||||
@@ -879,7 +944,7 @@ public sealed partial class ClientConnection
|
||||
|
||||
internal void ClaimExpiration()
|
||||
{
|
||||
// TODO: Implement when Server is available.
|
||||
AuthExpired();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -888,7 +953,7 @@ public sealed partial class ClientConnection
|
||||
|
||||
internal void FlushSignal()
|
||||
{
|
||||
// TODO: Signal the writeLoop via SemaphoreSlim/Monitor when ported.
|
||||
FlushClients(0);
|
||||
}
|
||||
|
||||
internal void EnqueueProtoAndFlush(ReadOnlySpan<byte> proto)
|
||||
@@ -953,7 +1018,12 @@ public sealed partial class ClientConnection
|
||||
internal void TraceInOp(string op, byte[] arg) { if (Trace) TraceOp("<", op, arg); }
|
||||
internal void TraceOutOp(string op, byte[] arg) { if (Trace) TraceOp(">", op, arg); }
|
||||
|
||||
private void TraceMsgInternal(byte[] msg, bool inbound, bool delivery) { }
|
||||
private void TraceMsgInternal(byte[] msg, bool inbound, bool delivery)
|
||||
{
|
||||
var dir = inbound ? "<" : ">";
|
||||
var marker = delivery ? "[DELIVER]" : "[MSG]";
|
||||
Tracef("{0} {1} {2}", dir, marker, Encoding.UTF8.GetString(msg));
|
||||
}
|
||||
private void TraceOp(string dir, string op, byte[] arg)
|
||||
{
|
||||
Tracef("%s %s %s", dir, op, arg is not null ? Encoding.UTF8.GetString(arg) : string.Empty);
|
||||
@@ -1075,26 +1145,93 @@ public sealed partial class ClientConnection
|
||||
// =========================================================================
|
||||
|
||||
// features 425-427: writeLoop / flushClients / readLoop
|
||||
internal void WriteLoop() { /* TODO session 09 */ }
|
||||
internal void FlushClients(long budget) { /* TODO session 09 */ }
|
||||
internal void WriteLoop() => FlushClients(long.MaxValue);
|
||||
internal void FlushClients(long budget)
|
||||
{
|
||||
try { _nc?.Flush(); }
|
||||
catch { /* no-op for now */ }
|
||||
}
|
||||
internal void ReadLoop(byte[]? pre)
|
||||
{
|
||||
LastIn = DateTime.UtcNow;
|
||||
if (pre is { Length: > 0 })
|
||||
TraceInOp("PRE", pre);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the INFO JSON bytes sent to the client on connect.
|
||||
/// Stub — full implementation in session 09.
|
||||
/// Mirrors Go <c>client.generateClientInfoJSON()</c>.
|
||||
/// </summary>
|
||||
internal ReadOnlyMemory<byte> GenerateClientInfoJSON(ServerInfo info, bool includeClientIp)
|
||||
=> ReadOnlyMemory<byte>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the auth-timeout timer to the specified duration.
|
||||
/// Mirrors Go <c>client.setAuthTimer(d)</c>.
|
||||
/// </summary>
|
||||
internal void SetAuthTimer(TimeSpan d)
|
||||
{
|
||||
var prev = Interlocked.Exchange(ref _atmr, null);
|
||||
prev?.Dispose();
|
||||
if (d <= TimeSpan.Zero)
|
||||
return;
|
||||
_atmr = new Timer(_ => AuthTimeout(), null, d, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
// features 428-432: closedStateForErr, collapsePtoNB, flushOutbound, handleWriteTimeout, markConnAsClosed
|
||||
internal static ClosedState ClosedStateForErr(Exception err) =>
|
||||
err is EndOfStreamException ? ClosedState.ClientClosed : ClosedState.ReadError;
|
||||
|
||||
// features 440-441: processInfo, processErr
|
||||
internal void ProcessInfo(string info) { /* TODO session 09 */ }
|
||||
internal void ProcessErr(string err) { /* TODO session 09 */ }
|
||||
internal void ProcessInfo(string info)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(info))
|
||||
return;
|
||||
Debugf("INFO {0}", info);
|
||||
}
|
||||
internal void ProcessErr(string err)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(err))
|
||||
return;
|
||||
SetAuthError(new InvalidOperationException(err));
|
||||
Errorf("-ERR {0}", err);
|
||||
}
|
||||
|
||||
// features 442-443: removeSecretsFromTrace, redact
|
||||
internal static string RemoveSecretsFromTrace(string s) => s;
|
||||
// Delegates to ServerLogging.RemoveSecretsFromTrace (the real implementation lives there).
|
||||
internal static string RemoveSecretsFromTrace(string s) => ServerLogging.RemoveSecretsFromTrace(s);
|
||||
internal static string Redact(string s) => s;
|
||||
|
||||
// feature 444: computeRTT
|
||||
internal static TimeSpan ComputeRtt(DateTime start) => DateTime.UtcNow - start;
|
||||
|
||||
// feature 445: processConnect
|
||||
internal void ProcessConnect(byte[] arg) { /* TODO session 09 */ }
|
||||
internal void ProcessConnect(byte[] arg)
|
||||
{
|
||||
if (arg == null || arg.Length == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<ClientOptions>(arg);
|
||||
if (parsed != null)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
Opts = parsed;
|
||||
Echo = parsed.Echo;
|
||||
Headers = parsed.Headers;
|
||||
Flags |= ClientFlags.ConnectReceived;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetAuthError(ex);
|
||||
Errorf("CONNECT parse failed: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// feature 467-468: processPing, processPong
|
||||
internal void ProcessPing()
|
||||
@@ -1103,10 +1240,19 @@ public sealed partial class ClientConnection
|
||||
SendPong();
|
||||
}
|
||||
|
||||
internal void ProcessPong() { /* TODO */ }
|
||||
internal void ProcessPong()
|
||||
{
|
||||
Rtt = ComputeRtt(RttStart);
|
||||
_pingOut = 0;
|
||||
}
|
||||
|
||||
// feature 469: updateS2AutoCompressionLevel
|
||||
internal void UpdateS2AutoCompressionLevel() { /* TODO */ }
|
||||
internal void UpdateS2AutoCompressionLevel()
|
||||
{
|
||||
// Placeholder for adaptive compression tuning; keep no-op semantics for now.
|
||||
if (_pingOut < 0)
|
||||
_pingOut = 0;
|
||||
}
|
||||
|
||||
// features 471-486: processPub variants, parseSub, processSub, etc.
|
||||
// Implemented in full when Server+Account sessions complete.
|
||||
|
||||
@@ -273,6 +273,13 @@ public sealed class ClientInfo
|
||||
public bool Disconnect { get; set; }
|
||||
public string[]? Cluster { get; set; }
|
||||
public bool Service { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip time to the client.
|
||||
/// Mirrors Go <c>RTT time.Duration</c> in events.go.
|
||||
/// Added here to support <see cref="ServiceLatency.NATSTotalTime"/>.
|
||||
/// </summary>
|
||||
public TimeSpan Rtt { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
171
dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs
Normal file
171
dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from parse utility functions in server/opts.go in the NATS server Go source.
|
||||
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to <see cref="TimeSpan"/>.
|
||||
/// Mirrors Go <c>parseDuration</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class NatsDurationJsonConverter : JsonConverter<TimeSpan>
|
||||
{
|
||||
private static readonly Regex Pattern = new(
|
||||
@"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString() ?? throw new JsonException("Expected a duration string");
|
||||
return Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(FormatDuration(value));
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601.
|
||||
/// </summary>
|
||||
public static TimeSpan Parse(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
throw new FormatException("Duration string is empty");
|
||||
|
||||
// Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s"
|
||||
var m = Pattern.Match(s);
|
||||
if (m.Success && m.Value.Length > 0)
|
||||
{
|
||||
var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
|
||||
var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
|
||||
var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
|
||||
var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0;
|
||||
var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0;
|
||||
var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0;
|
||||
return new TimeSpan(0, hours, minutes, seconds, ms)
|
||||
+ TimeSpan.FromMicroseconds(us)
|
||||
+ TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns
|
||||
}
|
||||
|
||||
// Try .NET TimeSpan.Parse (handles "hh:mm:ss")
|
||||
if (TimeSpan.TryParse(s, out var ts)) return ts;
|
||||
|
||||
throw new FormatException($"Cannot parse duration string: \"{s}\"");
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns";
|
||||
if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms";
|
||||
if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s";
|
||||
if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s";
|
||||
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a TLS version string ("1.2", "1.3", "TLS12") to <see cref="SslProtocols"/>.
|
||||
/// Mirrors Go <c>parseTLSVersion</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class TlsVersionJsonConverter : JsonConverter<SslProtocols>
|
||||
{
|
||||
public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString()?.Trim() ?? string.Empty;
|
||||
return Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString());
|
||||
|
||||
public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch
|
||||
{
|
||||
"1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12,
|
||||
"1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13,
|
||||
_ => throw new FormatException($"Unknown TLS version: \"{s}\""),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and normalises a NATS URL string (nats://host:port).
|
||||
/// Mirrors Go <c>parseURL</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class NatsUrlJsonConverter : JsonConverter<string>
|
||||
{
|
||||
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var raw = reader.GetString() ?? string.Empty;
|
||||
return Normalise(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value);
|
||||
|
||||
public static string Normalise(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return url;
|
||||
url = url.Trim();
|
||||
if (!url.Contains("://")) url = "nats://" + url;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
throw new FormatException($"Invalid NATS URL: \"{url}\"");
|
||||
return uri.ToString().TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long).
|
||||
/// Mirrors Go <c>getStorageSize</c> in server/opts.go.
|
||||
/// </summary>
|
||||
public sealed class StorageSizeJsonConverter : JsonConverter<long>
|
||||
{
|
||||
private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
return reader.GetInt64();
|
||||
}
|
||||
var raw = reader.GetString() ?? "0";
|
||||
return Parse(raw);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
|
||||
=> writer.WriteNumberValue(value);
|
||||
|
||||
public static long Parse(string s)
|
||||
{
|
||||
// Mirrors Go getStorageSize: empty string returns 0 with no error.
|
||||
if (string.IsNullOrWhiteSpace(s)) return 0;
|
||||
if (long.TryParse(s, out var n)) return n;
|
||||
var m = Pattern.Match(s.Trim());
|
||||
if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\"");
|
||||
var num = double.Parse(m.Groups[1].Value);
|
||||
var suffix = m.Groups[2].Value.ToUpperInvariant();
|
||||
return suffix switch
|
||||
{
|
||||
"K" or "KB" => (long)(num * 1024),
|
||||
"M" or "MB" => (long)(num * 1024 * 1024),
|
||||
"G" or "GB" => (long)(num * 1024 * 1024 * 1024),
|
||||
"T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024),
|
||||
_ => (long)num,
|
||||
};
|
||||
}
|
||||
}
|
||||
996
dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs
Normal file
996
dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs
Normal file
@@ -0,0 +1,996 @@
|
||||
// Copyright 2017-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/reload.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// =============================================================================
|
||||
// IReloadOption — mirrors Go `option` interface in reload.go
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Represents a hot-swappable configuration setting that can be applied to a
|
||||
/// running server. Mirrors Go <c>option</c> interface in server/reload.go.
|
||||
/// </summary>
|
||||
public interface IReloadOption
|
||||
{
|
||||
/// <summary>Apply this option to the running server.</summary>
|
||||
void Apply(NatsServer server);
|
||||
|
||||
/// <summary>Returns true if this option requires reloading the logger.</summary>
|
||||
bool IsLoggingChange();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this option requires reloading the cached trace level.
|
||||
/// Clients store trace level separately.
|
||||
/// </summary>
|
||||
bool IsTraceLevelChange();
|
||||
|
||||
/// <summary>Returns true if this option requires reloading authorization.</summary>
|
||||
bool IsAuthChange();
|
||||
|
||||
/// <summary>Returns true if this option requires reloading TLS.</summary>
|
||||
bool IsTlsChange();
|
||||
|
||||
/// <summary>Returns true if this option requires reloading cluster permissions.</summary>
|
||||
bool IsClusterPermsChange();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this option requires special handling for changes in
|
||||
/// cluster pool size or accounts list.
|
||||
/// </summary>
|
||||
bool IsClusterPoolSizeOrAccountsChange();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this option indicates a change in the server's JetStream config.
|
||||
/// Account changes are handled separately in reloadAuthorization.
|
||||
/// </summary>
|
||||
bool IsJetStreamChange();
|
||||
|
||||
/// <summary>Returns true if this change requires publishing the server's statz.</summary>
|
||||
bool IsStatszChange();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NoopReloadOption — mirrors Go `noopOption` struct in reload.go
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Base class providing no-op implementations for all <see cref="IReloadOption"/>
|
||||
/// methods. Concrete option types override only the methods relevant to them.
|
||||
/// Mirrors Go <c>noopOption</c> struct in server/reload.go.
|
||||
/// </summary>
|
||||
public abstract class NoopReloadOption : IReloadOption
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public virtual void Apply(NatsServer server) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsLoggingChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsTraceLevelChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsAuthChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsTlsChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsClusterPermsChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsClusterPoolSizeOrAccountsChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsJetStreamChange() => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsStatszChange() => false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Intermediate base classes (mirrors Go loggingOption / traceLevelOption)
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Base for all logging-related reload options.
|
||||
/// Mirrors Go <c>loggingOption</c> struct.
|
||||
/// </summary>
|
||||
internal abstract class LoggingReloadOption : NoopReloadOption
|
||||
{
|
||||
public override bool IsLoggingChange() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base for all trace-level reload options.
|
||||
/// Mirrors Go <c>traceLevelOption</c> struct.
|
||||
/// </summary>
|
||||
internal abstract class TraceLevelReloadOption : LoggingReloadOption
|
||||
{
|
||||
public override bool IsTraceLevelChange() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base for all authorization-related reload options.
|
||||
/// Mirrors Go <c>authOption</c> struct.
|
||||
/// </summary>
|
||||
internal abstract class AuthReloadOption : NoopReloadOption
|
||||
{
|
||||
public override bool IsAuthChange() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base for TLS reload options.
|
||||
/// Mirrors Go <c>tlsOption</c> (as a base, not the concrete type).
|
||||
/// </summary>
|
||||
internal abstract class TlsBaseReloadOption : NoopReloadOption
|
||||
{
|
||||
public override bool IsTlsChange() => true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Logging & Trace option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>trace</c> setting.
|
||||
/// Mirrors Go <c>traceOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TraceReloadOption : TraceLevelReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public TraceReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: trace = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>trace_verbose</c> setting.
|
||||
/// Mirrors Go <c>traceVerboseOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TraceVerboseReloadOption : TraceLevelReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public TraceVerboseReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: trace_verbose = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>trace_headers</c> setting.
|
||||
/// Mirrors Go <c>traceHeadersOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TraceHeadersReloadOption : TraceLevelReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public TraceHeadersReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: trace_headers = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>debug</c> setting.
|
||||
/// Mirrors Go <c>debugOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class DebugReloadOption : LoggingReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public DebugReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
server.Noticef("Reloaded: debug = {0}", _newValue);
|
||||
// TODO: session 13 — call server.ReloadDebugRaftNodes(_newValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>logtime</c> setting.
|
||||
/// Mirrors Go <c>logtimeOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class LogtimeReloadOption : LoggingReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public LogtimeReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: logtime = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>logtime_utc</c> setting.
|
||||
/// Mirrors Go <c>logtimeUTCOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class LogtimeUtcReloadOption : LoggingReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public LogtimeUtcReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: logtime_utc = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>log_file</c> setting.
|
||||
/// Mirrors Go <c>logfileOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class LogFileReloadOption : LoggingReloadOption
|
||||
{
|
||||
private readonly string _newValue;
|
||||
public LogFileReloadOption(string newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: log_file = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>syslog</c> setting.
|
||||
/// Mirrors Go <c>syslogOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class SyslogReloadOption : LoggingReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public SyslogReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: syslog = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>remote_syslog</c> setting.
|
||||
/// Mirrors Go <c>remoteSyslogOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class RemoteSyslogReloadOption : LoggingReloadOption
|
||||
{
|
||||
private readonly string _newValue;
|
||||
public RemoteSyslogReloadOption(string newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: remote_syslog = {0}", _newValue);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TLS option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>tls</c> setting.
|
||||
/// Mirrors Go <c>tlsOption</c> struct in reload.go.
|
||||
/// The TLS config is stored as <c>object?</c> because the full
|
||||
/// <c>TlsConfig</c> type is not yet ported.
|
||||
/// TODO: session 13 — replace object? with the ported TlsConfig type.
|
||||
/// </summary>
|
||||
internal sealed class TlsReloadOption : NoopReloadOption
|
||||
{
|
||||
// TODO: session 13 — replace object? with ported TlsConfig type
|
||||
private readonly object? _newValue;
|
||||
public TlsReloadOption(object? newValue) => _newValue = newValue;
|
||||
|
||||
public override bool IsTlsChange() => true;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
var message = _newValue is null ? "disabled" : "enabled";
|
||||
server.Noticef("Reloaded: tls = {0}", message);
|
||||
// TODO: session 13 — update server.Info.TLSRequired / TLSVerify
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the TLS <c>timeout</c> setting.
|
||||
/// Mirrors Go <c>tlsTimeoutOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TlsTimeoutReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly double _newValue;
|
||||
public TlsTimeoutReloadOption(double newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: tls timeout = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the TLS <c>pinned_certs</c> setting.
|
||||
/// Mirrors Go <c>tlsPinnedCertOption</c> struct in reload.go.
|
||||
/// The pinned cert set is stored as <c>object?</c> pending the port
|
||||
/// of the PinnedCertSet type.
|
||||
/// TODO: session 13 — replace object? with ported PinnedCertSet type.
|
||||
/// </summary>
|
||||
internal sealed class TlsPinnedCertReloadOption : NoopReloadOption
|
||||
{
|
||||
// TODO: session 13 — replace object? with ported PinnedCertSet type
|
||||
private readonly object? _newValue;
|
||||
public TlsPinnedCertReloadOption(object? newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: pinned_certs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the TLS <c>handshake_first</c> setting.
|
||||
/// Mirrors Go <c>tlsHandshakeFirst</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TlsHandshakeFirstReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public TlsHandshakeFirstReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: Client TLS handshake first: {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the TLS <c>handshake_first_fallback</c> delay setting.
|
||||
/// Mirrors Go <c>tlsHandshakeFirstFallback</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TlsHandshakeFirstFallbackReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly TimeSpan _newValue;
|
||||
public TlsHandshakeFirstFallbackReloadOption(TimeSpan newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: Client TLS handshake first fallback delay: {0}", _newValue);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Authorization option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>username</c> authorization setting.
|
||||
/// Mirrors Go <c>usernameOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class UsernameReloadOption : AuthReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: authorization username");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>password</c> authorization setting.
|
||||
/// Mirrors Go <c>passwordOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class PasswordReloadOption : AuthReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: authorization password");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>token</c> authorization setting.
|
||||
/// Mirrors Go <c>authorizationOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class AuthorizationReloadOption : AuthReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: authorization token");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the authorization <c>timeout</c> setting.
|
||||
/// Note: this is a NoopReloadOption (not auth) because authorization
|
||||
/// will be reloaded with options separately.
|
||||
/// Mirrors Go <c>authTimeoutOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class AuthTimeoutReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly double _newValue;
|
||||
public AuthTimeoutReloadOption(double newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: authorization timeout = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>tags</c> setting.
|
||||
/// Mirrors Go <c>tagsOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class TagsReloadOption : NoopReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: tags");
|
||||
|
||||
public override bool IsStatszChange() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>metadata</c> setting.
|
||||
/// Mirrors Go <c>metadataOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MetadataReloadOption : NoopReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: metadata");
|
||||
|
||||
public override bool IsStatszChange() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the authorization <c>users</c> setting.
|
||||
/// Mirrors Go <c>usersOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class UsersReloadOption : AuthReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: authorization users");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the authorization <c>nkeys</c> setting.
|
||||
/// Mirrors Go <c>nkeysOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class NkeysReloadOption : AuthReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: authorization nkey users");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>accounts</c> setting.
|
||||
/// Mirrors Go <c>accountsOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class AccountsReloadOption : AuthReloadOption
|
||||
{
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: accounts");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cluster option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>cluster</c> setting.
|
||||
/// Stores cluster options as <c>object?</c> pending the port of <c>ClusterOpts</c>.
|
||||
/// Mirrors Go <c>clusterOption</c> struct in reload.go.
|
||||
/// TODO: session 13 — replace object? with ported ClusterOpts type.
|
||||
/// </summary>
|
||||
internal sealed class ClusterReloadOption : AuthReloadOption
|
||||
{
|
||||
// TODO: session 13 — replace object? with ported ClusterOpts type
|
||||
private readonly object? _newValue;
|
||||
private readonly bool _permsChanged;
|
||||
private readonly bool _poolSizeChanged;
|
||||
private readonly bool _compressChanged;
|
||||
private readonly string[] _accsAdded;
|
||||
private readonly string[] _accsRemoved;
|
||||
|
||||
public ClusterReloadOption(
|
||||
object? newValue,
|
||||
bool permsChanged,
|
||||
bool poolSizeChanged,
|
||||
bool compressChanged,
|
||||
string[] accsAdded,
|
||||
string[] accsRemoved)
|
||||
{
|
||||
_newValue = newValue;
|
||||
_permsChanged = permsChanged;
|
||||
_poolSizeChanged = poolSizeChanged;
|
||||
_compressChanged = compressChanged;
|
||||
_accsAdded = accsAdded;
|
||||
_accsRemoved = accsRemoved;
|
||||
}
|
||||
|
||||
public override bool IsClusterPermsChange()
|
||||
=> _permsChanged;
|
||||
|
||||
public override bool IsClusterPoolSizeOrAccountsChange()
|
||||
=> _poolSizeChanged || _accsAdded.Length > 0 || _accsRemoved.Length > 0;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — full cluster apply logic (TLS, route info, compression)
|
||||
server.Noticef("Reloaded: cluster");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the cluster <c>routes</c> setting.
|
||||
/// Routes to add/remove are stored as <c>object[]</c> pending the port of URL handling.
|
||||
/// Mirrors Go <c>routesOption</c> struct in reload.go.
|
||||
/// TODO: session 13 — replace object[] with Uri[] when route types are ported.
|
||||
/// </summary>
|
||||
internal sealed class RoutesReloadOption : NoopReloadOption
|
||||
{
|
||||
// TODO: session 13 — replace object[] with Uri[] when route URL types are ported
|
||||
private readonly object[] _add;
|
||||
private readonly object[] _remove;
|
||||
|
||||
public RoutesReloadOption(object[] add, object[] remove)
|
||||
{
|
||||
_add = add;
|
||||
_remove = remove;
|
||||
}
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — add/remove routes, update varzUpdateRouteURLs
|
||||
server.Noticef("Reloaded: cluster routes");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection limit & network option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>max_connections</c> setting.
|
||||
/// Mirrors Go <c>maxConnOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MaxConnReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MaxConnReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — close random connections if over limit
|
||||
server.Noticef("Reloaded: max_connections = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>pid_file</c> setting.
|
||||
/// Mirrors Go <c>pidFileOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class PidFileReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly string _newValue;
|
||||
public PidFileReloadOption(string newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_newValue))
|
||||
return;
|
||||
// TODO: session 13 — call server.LogPid()
|
||||
server.Noticef("Reloaded: pid_file = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>ports_file_dir</c> setting.
|
||||
/// Mirrors Go <c>portsFileDirOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class PortsFileDirReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly string _oldValue;
|
||||
private readonly string _newValue;
|
||||
|
||||
public PortsFileDirReloadOption(string oldValue, string newValue)
|
||||
{
|
||||
_oldValue = oldValue;
|
||||
_newValue = newValue;
|
||||
}
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts()
|
||||
server.Noticef("Reloaded: ports_file_dir = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>max_control_line</c> setting.
|
||||
/// Mirrors Go <c>maxControlLineOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MaxControlLineReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MaxControlLineReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — update mcl on each connected client
|
||||
server.Noticef("Reloaded: max_control_line = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>max_payload</c> setting.
|
||||
/// Mirrors Go <c>maxPayloadOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MaxPayloadReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MaxPayloadReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — update server info and mpay on each client
|
||||
server.Noticef("Reloaded: max_payload = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>ping_interval</c> setting.
|
||||
/// Mirrors Go <c>pingIntervalOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class PingIntervalReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly TimeSpan _newValue;
|
||||
public PingIntervalReloadOption(TimeSpan newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: ping_interval = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>ping_max</c> setting.
|
||||
/// Mirrors Go <c>maxPingsOutOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MaxPingsOutReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MaxPingsOutReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: ping_max = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>write_deadline</c> setting.
|
||||
/// Mirrors Go <c>writeDeadlineOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class WriteDeadlineReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly TimeSpan _newValue;
|
||||
public WriteDeadlineReloadOption(TimeSpan newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: write_deadline = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>client_advertise</c> setting.
|
||||
/// Mirrors Go <c>clientAdvertiseOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class ClientAdvertiseReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly string _newValue;
|
||||
public ClientAdvertiseReloadOption(string newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — call server.SetInfoHostPort()
|
||||
server.Noticef("Reload: client_advertise = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JetStream option type
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>jetstream</c> setting.
|
||||
/// Mirrors Go <c>jetStreamOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class JetStreamReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public JetStreamReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override bool IsJetStreamChange() => true;
|
||||
public override bool IsStatszChange() => true;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: JetStream");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Miscellaneous option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>default_sentinel</c> setting.
|
||||
/// Mirrors Go <c>defaultSentinelOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class DefaultSentinelReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly string _newValue;
|
||||
public DefaultSentinelReloadOption(string newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: default_sentinel = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the OCSP setting.
|
||||
/// The new value is stored as <c>object?</c> pending the port of <c>OCSPConfig</c>.
|
||||
/// Mirrors Go <c>ocspOption</c> struct in reload.go.
|
||||
/// TODO: session 13 — replace object? with ported OcspConfig type.
|
||||
/// </summary>
|
||||
internal sealed class OcspReloadOption : TlsBaseReloadOption
|
||||
{
|
||||
// TODO: session 13 — replace object? with ported OcspConfig type
|
||||
private readonly object? _newValue;
|
||||
public OcspReloadOption(object? newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: OCSP");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the OCSP response cache setting.
|
||||
/// The new value is stored as <c>object?</c> pending the port of
|
||||
/// <c>OCSPResponseCacheConfig</c>.
|
||||
/// Mirrors Go <c>ocspResponseCacheOption</c> struct in reload.go.
|
||||
/// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type.
|
||||
/// </summary>
|
||||
internal sealed class OcspResponseCacheReloadOption : TlsBaseReloadOption
|
||||
{
|
||||
// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type
|
||||
private readonly object? _newValue;
|
||||
public OcspResponseCacheReloadOption(object? newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded OCSP peer cache");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>connect_error_reports</c> setting.
|
||||
/// Mirrors Go <c>connectErrorReports</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class ConnectErrorReportsReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public ConnectErrorReportsReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: connect_error_reports = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>reconnect_error_reports</c> setting.
|
||||
/// Mirrors Go <c>reconnectErrorReports</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class ReconnectErrorReportsReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public ReconnectErrorReportsReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: reconnect_error_reports = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>max_traced_msg_len</c> setting.
|
||||
/// Mirrors Go <c>maxTracedMsgLenOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MaxTracedMsgLenReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MaxTracedMsgLenReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — update server.Opts.MaxTracedMsgLen under lock
|
||||
server.Noticef("Reloaded: max_traced_msg_len = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MQTT option types
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the MQTT <c>ack_wait</c> setting.
|
||||
/// Mirrors Go <c>mqttAckWaitReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttAckWaitReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly TimeSpan _newValue;
|
||||
public MqttAckWaitReloadOption(TimeSpan newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: MQTT ack_wait = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the MQTT <c>max_ack_pending</c> setting.
|
||||
/// Mirrors Go <c>mqttMaxAckPendingReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttMaxAckPendingReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly ushort _newValue;
|
||||
public MqttMaxAckPendingReloadOption(ushort newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — call server.MqttUpdateMaxAckPending(_newValue)
|
||||
server.Noticef("Reloaded: MQTT max_ack_pending = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the MQTT <c>stream_replicas</c> setting.
|
||||
/// Mirrors Go <c>mqttStreamReplicasReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttStreamReplicasReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MqttStreamReplicasReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: MQTT stream_replicas = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the MQTT <c>consumer_replicas</c> setting.
|
||||
/// Mirrors Go <c>mqttConsumerReplicasReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttConsumerReplicasReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public MqttConsumerReplicasReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: MQTT consumer_replicas = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the MQTT <c>consumer_memory_storage</c> setting.
|
||||
/// Mirrors Go <c>mqttConsumerMemoryStorageReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttConsumerMemoryStorageReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly bool _newValue;
|
||||
public MqttConsumerMemoryStorageReloadOption(bool newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: MQTT consumer_memory_storage = {0}", _newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the MQTT <c>consumer_inactive_threshold</c> setting.
|
||||
/// Mirrors Go <c>mqttInactiveThresholdReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttInactiveThresholdReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly TimeSpan _newValue;
|
||||
public MqttInactiveThresholdReloadOption(TimeSpan newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
=> server.Noticef("Reloaded: MQTT consumer_inactive_threshold = {0}", _newValue);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Profiling option type
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>prof_block_rate</c> setting.
|
||||
/// Mirrors Go <c>profBlockRateReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class ProfBlockRateReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly int _newValue;
|
||||
public ProfBlockRateReloadOption(int newValue) => _newValue = newValue;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — call server.SetBlockProfileRate(_newValue)
|
||||
server.Noticef("Reloaded: prof_block_rate = {0}", _newValue);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LeafNode option type
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for leaf-node settings (TLS handshake-first, compression, disabled).
|
||||
/// Mirrors Go <c>leafNodeOption</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class LeafNodeReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly bool _tlsFirstChanged;
|
||||
private readonly bool _compressionChanged;
|
||||
private readonly bool _disabledChanged;
|
||||
|
||||
public LeafNodeReloadOption(bool tlsFirstChanged, bool compressionChanged, bool disabledChanged)
|
||||
{
|
||||
_tlsFirstChanged = tlsFirstChanged;
|
||||
_compressionChanged = compressionChanged;
|
||||
_disabledChanged = disabledChanged;
|
||||
}
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply()
|
||||
if (_tlsFirstChanged)
|
||||
server.Noticef("Reloaded: LeafNode TLS HandshakeFirst settings");
|
||||
if (_compressionChanged)
|
||||
server.Noticef("Reloaded: LeafNode compression settings");
|
||||
if (_disabledChanged)
|
||||
server.Noticef("Reloaded: LeafNode disabled/enabled state");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NoFastProducerStall option type
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>no_fast_producer_stall</c> setting.
|
||||
/// Mirrors Go <c>noFastProdStallReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class NoFastProducerStallReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly bool _noStall;
|
||||
public NoFastProducerStallReloadOption(bool noStall) => _noStall = noStall;
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
var not = _noStall ? "not " : string.Empty;
|
||||
server.Noticef("Reloaded: fast producers will {0}be stalled", not);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Proxies option type
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reload option for the <c>proxies</c> trusted keys setting.
|
||||
/// Mirrors Go <c>proxiesReload</c> struct in reload.go.
|
||||
/// </summary>
|
||||
internal sealed class ProxiesReloadOption : NoopReloadOption
|
||||
{
|
||||
private readonly string[] _add;
|
||||
private readonly string[] _del;
|
||||
|
||||
public ProxiesReloadOption(string[] add, string[] del)
|
||||
{
|
||||
_add = add;
|
||||
_del = del;
|
||||
}
|
||||
|
||||
public override void Apply(NatsServer server)
|
||||
{
|
||||
// TODO: session 13 — disconnect proxied clients for removed keys,
|
||||
// call server.ProcessProxiesTrustedKeys()
|
||||
if (_del.Length > 0)
|
||||
server.Noticef("Reloaded: proxies trusted keys {0} were removed", string.Join(", ", _del));
|
||||
if (_add.Length > 0)
|
||||
server.Noticef("Reloaded: proxies trusted keys {0} were added", string.Join(", ", _add));
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ConfigReloader — stub for server/reload.go Reload() / ReloadOptions()
|
||||
// =============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Stub for the configuration reloader.
|
||||
/// Full reload logic (diffOptions, applyOptions, recheckPinnedCerts) will be
|
||||
/// implemented in a future session.
|
||||
/// Mirrors Go <c>Server.Reload()</c> and <c>Server.ReloadOptions()</c> in
|
||||
/// server/reload.go.
|
||||
/// </summary>
|
||||
internal sealed class ConfigReloader
|
||||
{
|
||||
// TODO: session 13 — full reload logic
|
||||
// Mirrors Go server.Reload() / server.ReloadOptions() in server/reload.go
|
||||
|
||||
/// <summary>
|
||||
/// Stub: read and apply the server config file.
|
||||
/// Returns null on success; a non-null Exception describes the failure.
|
||||
/// </summary>
|
||||
public Exception? Reload(NatsServer server) => null;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/opts.go in the NATS server Go source.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and binds NATS server configuration from an <c>appsettings.json</c> file.
|
||||
/// Replaces the Go <c>processConfigFile</c> / <c>processConfigFileLine</c> pipeline
|
||||
/// and all <c>parse*</c> helper functions in server/opts.go.
|
||||
/// </summary>
|
||||
public static class ServerOptionsConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JsonSerializerOptions"/> instance pre-configured with all
|
||||
/// NATS-specific JSON converters.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
opts.Converters.Add(new NatsDurationJsonConverter());
|
||||
opts.Converters.Add(new TlsVersionJsonConverter());
|
||||
opts.Converters.Add(new StorageSizeJsonConverter());
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON file at <paramref name="path"/> and returns a bound
|
||||
/// <see cref="ServerOptions"/> instance.
|
||||
/// Mirrors Go <c>ProcessConfigFile</c> and <c>Options.ProcessConfigFile</c>.
|
||||
/// </summary>
|
||||
public static ServerOptions ProcessConfigFile(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Configuration file not found: {path}", path);
|
||||
|
||||
var json = File.ReadAllText(path, Encoding.UTF8);
|
||||
return ProcessConfigString(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises a JSON string and returns a bound <see cref="ServerOptions"/> instance.
|
||||
/// Mirrors Go <c>Options.ProcessConfigString</c>.
|
||||
/// </summary>
|
||||
public static ServerOptions ProcessConfigString(string json)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNullOrEmpty(json);
|
||||
var opts = JsonSerializer.Deserialize<ServerOptions>(json, CreateJsonOptions())
|
||||
?? new ServerOptions();
|
||||
PostProcess(opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds a pre-built <see cref="IConfiguration"/> (e.g. from an ASP.NET Core host)
|
||||
/// to a <see cref="ServerOptions"/> instance.
|
||||
/// The configuration section should be the root or a named section such as "NatsServer".
|
||||
/// </summary>
|
||||
public static void BindConfiguration(IConfiguration config, ServerOptions target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
config.Bind(target);
|
||||
PostProcess(target);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Applies defaults and cross-field validation after loading.
|
||||
/// Mirrors the end of <c>Options.processConfigFile</c> and
|
||||
/// <c>configureSystemAccount</c> in server/opts.go.
|
||||
/// </summary>
|
||||
private static void PostProcess(ServerOptions opts)
|
||||
{
|
||||
// Apply default port if not set.
|
||||
if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort;
|
||||
|
||||
// Apply default host if not set.
|
||||
if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost;
|
||||
|
||||
// Apply default max payload.
|
||||
if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload;
|
||||
|
||||
// Apply default auth timeout.
|
||||
if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout;
|
||||
|
||||
// Apply default max control line size.
|
||||
if (opts.MaxControlLine == 0) opts.MaxControlLine = ServerConstants.MaxControlLineSize;
|
||||
|
||||
// Ensure SystemAccount defaults if not set.
|
||||
ConfigureSystemAccount(opts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the system account name from options.
|
||||
/// Mirrors Go <c>configureSystemAccount</c> in server/opts.go.
|
||||
/// </summary>
|
||||
private static void ConfigureSystemAccount(ServerOptions opts)
|
||||
{
|
||||
// If system account already set, nothing to do.
|
||||
if (!string.IsNullOrEmpty(opts.SystemAccount)) return;
|
||||
// Respect explicit opt-out.
|
||||
if (opts.NoSystemAccount) return;
|
||||
// Default to "$SYS" if not explicitly disabled.
|
||||
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
|
||||
}
|
||||
}
|
||||
778
dotnet/src/ZB.MOM.NatsNet.Server/Events/EventTypes.cs
Normal file
778
dotnet/src/ZB.MOM.NatsNet.Server/Events/EventTypes.cs
Normal file
@@ -0,0 +1,778 @@
|
||||
// Copyright 2018-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/events.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// System subject constants
|
||||
// Mirrors the const block at the top of server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// System-account subject templates and constants used for internal NATS server
|
||||
/// event routing. All format-string fields use <see cref="string.Format"/> with
|
||||
/// the appropriate server/account ID substituted at call time.
|
||||
/// Mirrors the const block in server/events.go.
|
||||
/// </summary>
|
||||
public static class SystemSubjects
|
||||
{
|
||||
// Account lookup / claims
|
||||
public const string AccLookupReqSubj = "$SYS.REQ.ACCOUNT.{0}.CLAIMS.LOOKUP";
|
||||
public const string AccPackReqSubj = "$SYS.REQ.CLAIMS.PACK";
|
||||
public const string AccListReqSubj = "$SYS.REQ.CLAIMS.LIST";
|
||||
public const string AccClaimsReqSubj = "$SYS.REQ.CLAIMS.UPDATE";
|
||||
public const string AccDeleteReqSubj = "$SYS.REQ.CLAIMS.DELETE";
|
||||
|
||||
// Connection events
|
||||
public const string ConnectEventSubj = "$SYS.ACCOUNT.{0}.CONNECT";
|
||||
public const string DisconnectEventSubj = "$SYS.ACCOUNT.{0}.DISCONNECT";
|
||||
|
||||
// Direct request routing
|
||||
public const string AccDirectReqSubj = "$SYS.REQ.ACCOUNT.{0}.{1}";
|
||||
public const string AccPingReqSubj = "$SYS.REQ.ACCOUNT.PING.{0}";
|
||||
|
||||
// Account update events (both old and new forms kept for backward compatibility)
|
||||
public const string AccUpdateEventSubjOld = "$SYS.ACCOUNT.{0}.CLAIMS.UPDATE";
|
||||
public const string AccUpdateEventSubjNew = "$SYS.REQ.ACCOUNT.{0}.CLAIMS.UPDATE";
|
||||
|
||||
public const string ConnsRespSubj = "$SYS._INBOX_.{0}";
|
||||
public const string AccConnsEventSubjNew = "$SYS.ACCOUNT.{0}.SERVER.CONNS";
|
||||
public const string AccConnsEventSubjOld = "$SYS.SERVER.ACCOUNT.{0}.CONNS"; // backward compat
|
||||
|
||||
// Server lifecycle events
|
||||
public const string LameDuckEventSubj = "$SYS.SERVER.{0}.LAMEDUCK";
|
||||
public const string ShutdownEventSubj = "$SYS.SERVER.{0}.SHUTDOWN";
|
||||
|
||||
// Client control
|
||||
public const string ClientKickReqSubj = "$SYS.REQ.SERVER.{0}.KICK";
|
||||
public const string ClientLdmReqSubj = "$SYS.REQ.SERVER.{0}.LDM";
|
||||
|
||||
// Auth error events
|
||||
public const string AuthErrorEventSubj = "$SYS.SERVER.{0}.CLIENT.AUTH.ERR";
|
||||
public const string AuthErrorAccountEventSubj = "$SYS.ACCOUNT.CLIENT.AUTH.ERR";
|
||||
|
||||
// Stats
|
||||
public const string ServerStatsSubj = "$SYS.SERVER.{0}.STATSZ";
|
||||
public const string ServerDirectReqSubj = "$SYS.REQ.SERVER.{0}.{1}";
|
||||
public const string ServerPingReqSubj = "$SYS.REQ.SERVER.PING.{0}";
|
||||
public const string ServerStatsPingReqSubj = "$SYS.REQ.SERVER.PING"; // deprecated; use STATSZ variant
|
||||
public const string ServerReloadReqSubj = "$SYS.REQ.SERVER.{0}.RELOAD";
|
||||
|
||||
// Leaf node
|
||||
public const string LeafNodeConnectEventSubj = "$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT"; // internal only
|
||||
|
||||
// Latency
|
||||
public const string RemoteLatencyEventSubj = "$SYS.LATENCY.M2.{0}";
|
||||
public const string InboxRespSubj = "$SYS._INBOX.{0}.{1}";
|
||||
|
||||
// User info
|
||||
public const string UserDirectInfoSubj = "$SYS.REQ.USER.INFO";
|
||||
public const string UserDirectReqSubj = "$SYS.REQ.USER.{0}.INFO";
|
||||
|
||||
// Subscription count
|
||||
public const string AccNumSubsReqSubj = "$SYS.REQ.ACCOUNT.NSUBS";
|
||||
|
||||
// Debug
|
||||
public const string AccSubsSubj = "$SYS.DEBUG.SUBSCRIBERS";
|
||||
|
||||
// OCSP peer events
|
||||
public const string OcspPeerRejectEventSubj = "$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT";
|
||||
public const string OcspPeerChainlinkInvalidEventSubj = "$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID";
|
||||
|
||||
// Parsing constants (token indexes / counts)
|
||||
public const int AccLookupReqTokens = 6;
|
||||
public const int ShutdownEventTokens = 4;
|
||||
public const int ServerSubjectIndex = 2;
|
||||
public const int AccUpdateTokensNew = 6;
|
||||
public const int AccUpdateTokensOld = 5;
|
||||
public const int AccUpdateAccIdxOld = 2;
|
||||
public const int AccReqTokens = 5;
|
||||
public const int AccReqAccIndex = 3;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Advisory message type schema URI constants
|
||||
// Mirrors the const string variables near each struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
public static class EventMsgTypes
|
||||
{
|
||||
public const string ConnectEventMsgType = "io.nats.server.advisory.v1.client_connect";
|
||||
public const string DisconnectEventMsgType = "io.nats.server.advisory.v1.client_disconnect";
|
||||
public const string OcspPeerRejectEventMsgType = "io.nats.server.advisory.v1.ocsp_peer_reject";
|
||||
public const string OcspPeerChainlinkInvalidEventMsgType = "io.nats.server.advisory.v1.ocsp_peer_link_invalid";
|
||||
public const string AccountNumConnsMsgType = "io.nats.server.advisory.v1.account_connections";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Heartbeat / rate-limit intervals (mirrors package-level vars in events.go)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Default timing constants for server event heartbeats and rate limiting.
|
||||
/// Mirrors Go package-level <c>var</c> declarations in events.go.
|
||||
/// </summary>
|
||||
public static class EventIntervals
|
||||
{
|
||||
/// <summary>Default HB interval for events. Mirrors Go <c>eventsHBInterval = 30s</c>.</summary>
|
||||
public static readonly TimeSpan EventsHbInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Default HB interval for stats. Mirrors Go <c>statsHBInterval = 10s</c>.</summary>
|
||||
public static readonly TimeSpan StatsHbInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Minimum interval between statsz publishes. Mirrors Go <c>defaultStatszRateLimit = 1s</c>.</summary>
|
||||
public static readonly TimeSpan DefaultStatszRateLimit = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SysMsgHandler — delegate for internal system message dispatch
|
||||
// Mirrors Go <c>sysMsgHandler</c> func type in events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when an internal system message is dispatched.
|
||||
/// Mirrors Go <c>sysMsgHandler</c> in server/events.go.
|
||||
/// </summary>
|
||||
public delegate void SysMsgHandler(
|
||||
Subscription sub,
|
||||
NatsClient client,
|
||||
Account acc,
|
||||
string subject,
|
||||
string reply,
|
||||
byte[] hdr,
|
||||
byte[] msg);
|
||||
|
||||
// ============================================================================
|
||||
// InSysMsg — queued internal system message
|
||||
// Mirrors Go <c>inSysMsg</c> struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds a system message queued for internal delivery, avoiding the
|
||||
/// route/gateway path.
|
||||
/// Mirrors Go <c>inSysMsg</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
internal sealed class InSysMsg
|
||||
{
|
||||
public Subscription? Sub { get; set; }
|
||||
public NatsClient? Client { get; set; }
|
||||
public Account? Acc { get; set; }
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Reply { get; set; } = string.Empty;
|
||||
public byte[]? Hdr { get; set; }
|
||||
public byte[]? Msg { get; set; }
|
||||
public SysMsgHandler? Cb { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InternalState — server internal/sys state
|
||||
// Mirrors Go <c>internal</c> struct in server/events.go.
|
||||
// Uses Monitor lock (lock(this)) in place of Go's embedded sync.Mutex.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds all internal state used by the server's system-account event
|
||||
/// machinery: account reference, client, send/receive queues, timers,
|
||||
/// reply handlers, and heartbeat configuration.
|
||||
/// Mirrors Go <c>internal</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
internal sealed class InternalState
|
||||
{
|
||||
// ---- identity / sequencing ----
|
||||
public Account? Account { get; set; }
|
||||
public NatsClient? Client { get; set; }
|
||||
public ulong Seq { get; set; }
|
||||
public int Sid { get; set; }
|
||||
|
||||
// ---- remote server tracking ----
|
||||
/// <summary>Map of server ID → serverUpdate. Mirrors Go <c>servers map[string]*serverUpdate</c>.</summary>
|
||||
public Dictionary<string, ServerUpdate> Servers { get; set; } = new();
|
||||
|
||||
// ---- timers ----
|
||||
/// <summary>Sweeper timer. Mirrors Go <c>sweeper *time.Timer</c>.</summary>
|
||||
public System.Threading.Timer? Sweeper { get; set; }
|
||||
|
||||
/// <summary>Stats heartbeat timer. Mirrors Go <c>stmr *time.Timer</c>.</summary>
|
||||
public System.Threading.Timer? StatsMsgTimer { get; set; }
|
||||
|
||||
// ---- reply handlers ----
|
||||
/// <summary>
|
||||
/// Pending reply subject → handler map.
|
||||
/// Mirrors Go <c>replies map[string]msgHandler</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, Action<Subscription, NatsClient, Account, string, string, byte[], byte[]>> Replies { get; set; } = new();
|
||||
|
||||
// ---- queues ----
|
||||
/// <summary>Outbound message send queue. Mirrors Go <c>sendq *ipQueue[*pubMsg]</c>.</summary>
|
||||
public IpQueue<PubMsg>? SendQueue { get; set; }
|
||||
|
||||
/// <summary>Inbound receive queue. Mirrors Go <c>recvq *ipQueue[*inSysMsg]</c>.</summary>
|
||||
public IpQueue<InSysMsg>? RecvQueue { get; set; }
|
||||
|
||||
/// <summary>Priority receive queue for STATSZ/Pings. Mirrors Go <c>recvqp *ipQueue[*inSysMsg]</c>.</summary>
|
||||
public IpQueue<InSysMsg>? RecvQueuePriority { get; set; }
|
||||
|
||||
/// <summary>Reset channel used to restart the send loop. Mirrors Go <c>resetCh chan struct{}</c>.</summary>
|
||||
public System.Threading.Channels.Channel<bool>? ResetChannel { get; set; }
|
||||
|
||||
// ---- durations ----
|
||||
/// <summary>Maximum time before an orphaned server entry is removed. Mirrors Go <c>orphMax</c>.</summary>
|
||||
public TimeSpan OrphanMax { get; set; }
|
||||
|
||||
/// <summary>Interval at which orphan checks run. Mirrors Go <c>chkOrph</c>.</summary>
|
||||
public TimeSpan CheckOrphan { get; set; }
|
||||
|
||||
/// <summary>Interval between statsz publishes. Mirrors Go <c>statsz</c>.</summary>
|
||||
public TimeSpan StatszInterval { get; set; }
|
||||
|
||||
/// <summary>Client-facing statsz interval. Mirrors Go <c>cstatsz</c>.</summary>
|
||||
public TimeSpan ClientStatszInterval { get; set; }
|
||||
|
||||
// ---- misc ----
|
||||
/// <summary>Short hash used for shared-inbox routing. Mirrors Go <c>shash string</c>.</summary>
|
||||
public string ShortHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Inbox prefix for this server's internal client. Mirrors Go <c>inboxPre string</c>.</summary>
|
||||
public string InboxPrefix { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Subscription for remote stats. Mirrors Go <c>remoteStatsSub *subscription</c>.</summary>
|
||||
public Subscription? RemoteStatsSub { get; set; }
|
||||
|
||||
/// <summary>Time of the last statsz publish. Mirrors Go <c>lastStatsz time.Time</c>.</summary>
|
||||
public DateTime LastStatsz { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServerUpdate — remote server heartbeat tracking
|
||||
// Mirrors Go <c>serverUpdate</c> struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the sequence number and last-seen timestamp of a remote server's
|
||||
/// system heartbeat. Used to detect orphaned servers.
|
||||
/// Mirrors Go <c>serverUpdate</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
internal sealed class ServerUpdate
|
||||
{
|
||||
/// <summary>Last sequence number received from the remote server.</summary>
|
||||
public ulong Seq { get; set; }
|
||||
|
||||
/// <summary>Wall-clock time of the last heartbeat.</summary>
|
||||
public DateTime LTime { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PubMsg — internally-queued outbound publish message
|
||||
// Mirrors Go <c>pubMsg</c> struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds an outbound message that the server wants to publish via the internal
|
||||
/// send loop, avoiding direct route/gateway writes.
|
||||
/// Mirrors Go <c>pubMsg</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
internal sealed class PubMsg
|
||||
{
|
||||
public NatsClient? Client { get; set; }
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Reply { get; set; } = string.Empty;
|
||||
public ServerInfo? Si { get; set; }
|
||||
public byte[]? Hdr { get; set; }
|
||||
public object? Msg { get; set; }
|
||||
|
||||
/// <summary>Compression type. TODO: session 12 — wire up compressionType enum.</summary>
|
||||
public int Oct { get; set; }
|
||||
|
||||
public bool Echo { get; set; }
|
||||
public bool Last { get; set; }
|
||||
|
||||
// TODO: session 12 — add pool return helper (returnToPool).
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DataStats — message/byte counter pair (sent or received)
|
||||
// Mirrors Go <c>DataStats</c> struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reports how many messages and bytes were sent or received.
|
||||
/// Optionally breaks out gateway, route, and leaf-node traffic.
|
||||
/// Mirrors Go <c>DataStats</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class DataStats
|
||||
{
|
||||
[JsonPropertyName("msgs")]
|
||||
public long Msgs { get; set; }
|
||||
|
||||
[JsonPropertyName("bytes")]
|
||||
public long Bytes { get; set; }
|
||||
|
||||
[JsonPropertyName("gateways")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public MsgBytes? Gateways { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public MsgBytes? Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("leafs")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public MsgBytes? Leafs { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgBytes — simple message+byte pair used inside DataStats
|
||||
// Mirrors Go <c>MsgBytes</c> struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A simple pair of message and byte counts, used as a nested breakdown
|
||||
/// inside <see cref="DataStats"/>.
|
||||
/// Mirrors Go <c>MsgBytes</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class MsgBytes
|
||||
{
|
||||
[JsonPropertyName("msgs")]
|
||||
public long Msgs { get; set; }
|
||||
|
||||
[JsonPropertyName("bytes")]
|
||||
public long Bytes { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RouteStat / GatewayStat — per-route and per-gateway stat snapshots
|
||||
// Mirrors Go <c>RouteStat</c> and <c>GatewayStat</c> in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Statistics snapshot for a single cluster route connection.
|
||||
/// Mirrors Go <c>RouteStat</c> in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class RouteStat
|
||||
{
|
||||
[JsonPropertyName("rid")]
|
||||
public ulong Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("sent")]
|
||||
public DataStats Sent { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received")]
|
||||
public DataStats Received { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("pending")]
|
||||
public int Pending { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics snapshot for a gateway connection.
|
||||
/// Mirrors Go <c>GatewayStat</c> in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class GatewayStat
|
||||
{
|
||||
[JsonPropertyName("gwid")]
|
||||
public ulong Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sent")]
|
||||
public DataStats Sent { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received")]
|
||||
public DataStats Received { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("inbound_connections")]
|
||||
public int NumInbound { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServerStatsMsg — periodic stats advisory published on $SYS.SERVER.{id}.STATSZ
|
||||
// Mirrors Go <c>ServerStatsMsg</c> struct in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Periodic advisory message containing the current server statistics.
|
||||
/// Mirrors Go <c>ServerStatsMsg</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class ServerStatsMsg
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("statsz")]
|
||||
public ServerStatsAdvisory Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServerStatsAdvisory — the statsz payload inside ServerStatsMsg
|
||||
// Mirrors Go <c>ServerStats</c> struct (advisory form) in server/events.go.
|
||||
// NOTE: distinct from the internal ServerStats in NatsServerTypes.cs.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The JSON-serialisable statistics payload included inside <see cref="ServerStatsMsg"/>.
|
||||
/// Mirrors Go <c>ServerStats</c> struct (advisory form) in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class ServerStatsAdvisory
|
||||
{
|
||||
[JsonPropertyName("start")]
|
||||
public DateTime Start { get; set; }
|
||||
|
||||
[JsonPropertyName("mem")]
|
||||
public long Mem { get; set; }
|
||||
|
||||
[JsonPropertyName("cores")]
|
||||
public int Cores { get; set; }
|
||||
|
||||
[JsonPropertyName("cpu")]
|
||||
public double Cpu { get; set; }
|
||||
|
||||
[JsonPropertyName("connections")]
|
||||
public int Connections { get; set; }
|
||||
|
||||
[JsonPropertyName("total_connections")]
|
||||
public ulong TotalConnections { get; set; }
|
||||
|
||||
[JsonPropertyName("active_accounts")]
|
||||
public int ActiveAccounts { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
[JsonPropertyName("sent")]
|
||||
public DataStats Sent { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received")]
|
||||
public DataStats Received { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("slow_consumers")]
|
||||
public long SlowConsumers { get; set; }
|
||||
|
||||
[JsonPropertyName("slow_consumer_stats")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public SlowConsumersStats? SlowConsumersStats { get; set; }
|
||||
|
||||
[JsonPropertyName("stale_connections")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public long StaleConnections { get; set; }
|
||||
|
||||
[JsonPropertyName("stale_connection_stats")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public StaleConnectionStats? StaleConnectionStats { get; set; }
|
||||
|
||||
[JsonPropertyName("stalled_clients")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public long StalledClients { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<RouteStat>? Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("gateways")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<GatewayStat>? Gateways { get; set; }
|
||||
|
||||
[JsonPropertyName("active_servers")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int ActiveServers { get; set; }
|
||||
|
||||
/// <summary>JetStream stats. TODO: session 19 — wire JetStreamVarz type.</summary>
|
||||
[JsonPropertyName("jetstream")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public object? JetStream { get; set; }
|
||||
|
||||
[JsonPropertyName("gomemlimit")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public long MemLimit { get; set; }
|
||||
|
||||
[JsonPropertyName("gomaxprocs")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int MaxProcs { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SlowConsumersStats / StaleConnectionStats — advisory-layer per-kind counters
|
||||
// These are the JSON-serialisable variants used in ServerStatsAdvisory.
|
||||
// The internal atomic counters live in NatsServerTypes.cs (SlowConsumerStats /
|
||||
// StaleConnectionStats with different casing).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Per-kind slow-consumer counters included in stats advisories.
|
||||
/// Mirrors Go <c>SlowConsumersStats</c> in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class SlowConsumersStats
|
||||
{
|
||||
[JsonPropertyName("clients")]
|
||||
public ulong Clients { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
public ulong Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("gateways")]
|
||||
public ulong Gateways { get; set; }
|
||||
|
||||
[JsonPropertyName("leafs")]
|
||||
public ulong Leafs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-kind stale-connection counters included in stats advisories.
|
||||
/// Mirrors Go <c>StaleConnectionStats</c> in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class StaleConnectionStats
|
||||
{
|
||||
[JsonPropertyName("clients")]
|
||||
public ulong Clients { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
public ulong Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("gateways")]
|
||||
public ulong Gateways { get; set; }
|
||||
|
||||
[JsonPropertyName("leafs")]
|
||||
public ulong Leafs { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConnectEventMsg / DisconnectEventMsg — client lifecycle advisories
|
||||
// Mirrors Go structs in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Advisory published on <c>$SYS.ACCOUNT.{acc}.CONNECT</c> when a new
|
||||
/// client connection is established within a tracked account.
|
||||
/// Mirrors Go <c>ConnectEventMsg</c> in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class ConnectEventMsg : TypedEvent
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("client")]
|
||||
public ClientInfo Client { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory published on <c>$SYS.ACCOUNT.{acc}.DISCONNECT</c> when a
|
||||
/// previously-tracked client connection closes.
|
||||
/// Mirrors Go <c>DisconnectEventMsg</c> in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class DisconnectEventMsg : TypedEvent
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("client")]
|
||||
public ClientInfo Client { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("sent")]
|
||||
public DataStats Sent { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received")]
|
||||
public DataStats Received { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OCSPPeerRejectEventMsg / OCSPPeerChainlinkInvalidEventMsg
|
||||
// Mirrors Go structs in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Advisory published when a peer TLS handshake is rejected due to OCSP
|
||||
/// invalidation of the peer's leaf certificate.
|
||||
/// Mirrors Go <c>OCSPPeerRejectEventMsg</c> in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class OcspPeerRejectEventMsg : TypedEvent
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("peer")]
|
||||
public CertInfo Peer { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory published when a certificate in a valid TLS chain is found to be
|
||||
/// OCSP-invalid during a peer handshake. Both the invalid link and the
|
||||
/// peer's leaf cert are included.
|
||||
/// Mirrors Go <c>OCSPPeerChainlinkInvalidEventMsg</c> in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class OcspPeerChainlinkInvalidEventMsg : TypedEvent
|
||||
{
|
||||
[JsonPropertyName("link")]
|
||||
public CertInfo Link { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("peer")]
|
||||
public CertInfo Peer { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AccountNumConns / AccountStat — account connection count advisories
|
||||
// Mirrors Go structs in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Advisory heartbeat published when the connection count for a tracked
|
||||
/// account changes, or on a periodic schedule.
|
||||
/// Mirrors Go <c>AccountNumConns</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class AccountNumConns : TypedEvent
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
// Embedded AccountStat fields are inlined via composition.
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("conns")]
|
||||
public int Conns { get; set; }
|
||||
|
||||
[JsonPropertyName("leafnodes")]
|
||||
public int LeafNodes { get; set; }
|
||||
|
||||
[JsonPropertyName("total_conns")]
|
||||
public int TotalConns { get; set; }
|
||||
|
||||
[JsonPropertyName("num_subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
[JsonPropertyName("sent")]
|
||||
public DataStats Sent { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received")]
|
||||
public DataStats Received { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("slow_consumers")]
|
||||
public long SlowConsumers { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistic data common to <see cref="AccountNumConns"/> and account-level
|
||||
/// monitoring responses.
|
||||
/// Mirrors Go <c>AccountStat</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class AccountStat
|
||||
{
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("conns")]
|
||||
public int Conns { get; set; }
|
||||
|
||||
[JsonPropertyName("leafnodes")]
|
||||
public int LeafNodes { get; set; }
|
||||
|
||||
[JsonPropertyName("total_conns")]
|
||||
public int TotalConns { get; set; }
|
||||
|
||||
[JsonPropertyName("num_subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
[JsonPropertyName("sent")]
|
||||
public DataStats Sent { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("received")]
|
||||
public DataStats Received { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("slow_consumers")]
|
||||
public long SlowConsumers { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal request payload sent when this server first starts tracking an
|
||||
/// account, asking peer servers for their local connection counts.
|
||||
/// Mirrors Go <c>accNumConnsReq</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
internal sealed class AccNumConnsReq
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ServerCapability / ServerID — server identity and capability flags
|
||||
// Mirrors Go types in server/events.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Bit-flag capability set for a remote server.
|
||||
/// Mirrors Go <c>ServerCapability uint64</c> in server/events.go.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ServerCapability : ulong
|
||||
{
|
||||
/// <summary>No capabilities.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Server has JetStream enabled. Mirrors Go <c>JetStreamEnabled</c>.</summary>
|
||||
JetStreamEnabled = 1UL << 0,
|
||||
|
||||
/// <summary>New stream snapshot capability. Mirrors Go <c>BinaryStreamSnapshot</c>.</summary>
|
||||
BinaryStreamSnapshot = 1UL << 1,
|
||||
|
||||
/// <summary>Move NRG traffic out of system account. Mirrors Go <c>AccountNRG</c>.</summary>
|
||||
AccountNrg = 1UL << 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal static identity for a remote server (name, host, ID).
|
||||
/// Mirrors Go <c>ServerID</c> struct in server/events.go.
|
||||
/// </summary>
|
||||
public sealed class ServerIdentity
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
384
dotnet/src/ZB.MOM.NatsNet.Server/Gateway/GatewayTypes.cs
Normal file
384
dotnet/src/ZB.MOM.NatsNet.Server/Gateway/GatewayTypes.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
// Copyright 2018-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/gateway.go in the NATS server Go source.
|
||||
|
||||
using System.Threading;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Session 16: Gateways
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Represents the interest mode for a given account on a gateway connection.
|
||||
/// Mirrors Go <c>GatewayInterestMode</c> byte iota in gateway.go.
|
||||
/// Do not change values — they are part of the wire-level gossip protocol.
|
||||
/// </summary>
|
||||
public enum GatewayInterestMode : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Default mode: the cluster sends to a gateway unless told there is no
|
||||
/// interest (applies to plain subscribers only).
|
||||
/// </summary>
|
||||
Optimistic = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Transitioning: the gateway has been sending too many no-interest signals
|
||||
/// and is switching to <see cref="InterestOnly"/> mode for this account.
|
||||
/// </summary>
|
||||
Transitioning = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Interest-only mode: the cluster has sent all its subscription interest;
|
||||
/// the gateway only forwards messages when explicit interest is known.
|
||||
/// </summary>
|
||||
InterestOnly = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Internal sentinel used after a cache flush; not part of the public wire enum.
|
||||
/// </summary>
|
||||
CacheFlushed = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-level gateway state kept on the <see cref="NatsServer"/> instance.
|
||||
/// Replaces the stub that was in <c>NatsServerTypes.cs</c>.
|
||||
/// Mirrors Go <c>srvGateway</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class SrvGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of queue subs across all remote gateways.
|
||||
/// Accessed via <c>Interlocked</c> — must be 64-bit aligned.
|
||||
/// </summary>
|
||||
public long TotalQSubs;
|
||||
|
||||
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
|
||||
|
||||
/// <summary>
|
||||
/// True if both a gateway name and port are configured (immutable after init).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Name of this server's gateway cluster.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Outbound gateway connections keyed by remote gateway name.</summary>
|
||||
public Dictionary<string, ClientConnection> Out { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Outbound gateway connections in RTT order, used for message routing.
|
||||
/// </summary>
|
||||
public List<ClientConnection> Outo { get; set; } = [];
|
||||
|
||||
/// <summary>Inbound gateway connections keyed by connection ID.</summary>
|
||||
public Dictionary<ulong, ClientConnection> In { get; set; } = new();
|
||||
|
||||
/// <summary>Per-remote-gateway configuration, keyed by gateway name.</summary>
|
||||
public Dictionary<string, GatewayCfg> Remotes { get; set; } = new();
|
||||
|
||||
/// <summary>Reference-counted set of all gateway URLs in the cluster.</summary>
|
||||
public RefCountedUrlSet Urls { get; set; } = new();
|
||||
|
||||
/// <summary>This server's own gateway URL (after random-port resolution).</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gateway INFO protocol object.</summary>
|
||||
public ServerInfo? Info { get; set; }
|
||||
|
||||
/// <summary>Pre-marshalled INFO JSON bytes.</summary>
|
||||
public byte[]? InfoJson { get; set; }
|
||||
|
||||
/// <summary>When true, reject connections from gateways not in <see cref="Remotes"/>.</summary>
|
||||
public bool RejectUnknown { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reply prefix bytes: <c>"$GNR.<reserved>.<clusterHash>.<serverHash>."</c>
|
||||
/// </summary>
|
||||
public byte[] ReplyPfx { get; set; } = [];
|
||||
|
||||
// Backward-compatibility reply prefix and hash (old "$GR." scheme)
|
||||
public byte[] OldReplyPfx { get; set; } = [];
|
||||
public byte[] OldHash { get; set; } = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// pasi — per-account subject interest tally (protected by its own mutex)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Per-account subject-interest tally.
|
||||
/// Outer key = account name; inner key = subject (or "subject queue" pair);
|
||||
/// value = tally struct.
|
||||
/// Mirrors Go's anonymous <c>pasi</c> embedded struct in <c>srvGateway</c>.
|
||||
/// </summary>
|
||||
private readonly Lock _pasiLock = new();
|
||||
public Dictionary<string, Dictionary<string, SitAlly>> Pasi { get; set; } = new();
|
||||
|
||||
public Lock PasiLock => _pasiLock;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recent subscription tracking (thread-safe map)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Recent subscriptions for a given account (subject → expiry ticks).
|
||||
/// Mirrors Go's <c>rsubs sync.Map</c>.
|
||||
/// </summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, long> RSubs { get; set; } = new();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Other server-level gateway fields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>DNS resolver used before dialling gateway connections.</summary>
|
||||
public INetResolver? Resolver { get; set; }
|
||||
|
||||
/// <summary>Max buffer size for sending queue-sub protocol (used in tests).</summary>
|
||||
public int SqbSz { get; set; }
|
||||
|
||||
/// <summary>How long to look for a subscription match for a reply message.</summary>
|
||||
public TimeSpan RecSubExp { get; set; }
|
||||
|
||||
/// <summary>Server ID hash (6 bytes) for routing mapped replies.</summary>
|
||||
public byte[] SIdHash { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Map from a route server's hashed ID (6 bytes) to the route client.
|
||||
/// Mirrors Go's <c>routesIDByHash sync.Map</c>.
|
||||
/// </summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, ClientConnection> RoutesIdByHash { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gateway URLs from this server's own entry in the Gateways config block,
|
||||
/// used for monitoring reports.
|
||||
/// </summary>
|
||||
public List<string> OwnCfgUrls { get; set; } = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lock helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void AcquireReadLock() => _lock.EnterReadLock();
|
||||
public void ReleaseReadLock() => _lock.ExitReadLock();
|
||||
public void AcquireWriteLock() => _lock.EnterWriteLock();
|
||||
public void ReleaseWriteLock() => _lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject-interest tally entry. Indicates whether the key in the map is a
|
||||
/// queue subscription and how many matching subscriptions exist.
|
||||
/// Mirrors Go <c>sitally</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class SitAlly
|
||||
{
|
||||
/// <summary>Number of subscriptions directly matching the subject/queue key.</summary>
|
||||
public int N { get; set; }
|
||||
|
||||
/// <summary>True if this entry represents a queue subscription.</summary>
|
||||
public bool Q { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime configuration for a single remote gateway.
|
||||
/// Wraps <see cref="RemoteGatewayOpts"/> with connection-attempt state and a lock.
|
||||
/// Mirrors Go <c>gatewayCfg</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class GatewayCfg
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
|
||||
|
||||
/// <summary>The raw remote-gateway options this cfg was built from.</summary>
|
||||
public RemoteGatewayOpts? RemoteOpts { get; set; }
|
||||
|
||||
/// <summary>6-byte cluster hash used for reply routing.</summary>
|
||||
public byte[] Hash { get; set; } = [];
|
||||
|
||||
/// <summary>4-byte old-style hash for backward compatibility.</summary>
|
||||
public byte[] OldHash { get; set; } = [];
|
||||
|
||||
/// <summary>Map of URL string → parsed URL for this remote gateway.</summary>
|
||||
public Dictionary<string, Uri> Urls { get; set; } = new();
|
||||
|
||||
/// <summary>Number of connection attempts made so far.</summary>
|
||||
public int ConnAttempts { get; set; }
|
||||
|
||||
/// <summary>TLS server name override for SNI.</summary>
|
||||
public string TlsName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>True if this remote was discovered via gossip (not configured).</summary>
|
||||
public bool Implicit { get; set; }
|
||||
|
||||
/// <summary>When true, monitoring should refresh the URL list on next varz inspection.</summary>
|
||||
public bool VarzUpdateUrls { get; set; }
|
||||
|
||||
// Forwarded properties from RemoteGatewayOpts
|
||||
public string Name { get => RemoteOpts?.Name ?? string.Empty; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lock helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void AcquireReadLock() => _lock.EnterReadLock();
|
||||
public void ReleaseReadLock() => _lock.ExitReadLock();
|
||||
public void AcquireWriteLock() => _lock.EnterWriteLock();
|
||||
public void ReleaseWriteLock() => _lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection gateway state embedded in <see cref="ClientConnection"/>
|
||||
/// when the connection kind is <c>Gateway</c>.
|
||||
/// Mirrors Go <c>gateway</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class Gateway
|
||||
{
|
||||
/// <summary>Name of the remote gateway cluster.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Configuration block for the remote gateway.</summary>
|
||||
public GatewayCfg? Cfg { get; set; }
|
||||
|
||||
/// <summary>URL used for CONNECT after receiving the remote INFO (outbound only).</summary>
|
||||
public Uri? ConnectUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-account subject interest (outbound connection).
|
||||
/// Maps account name → <see cref="OutSide"/> for that account.
|
||||
/// Uses a thread-safe map because it is read from multiple goroutines.
|
||||
/// </summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, OutSide>? OutSim { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-account no-interest subjects or interest-only mode (inbound connection).
|
||||
/// </summary>
|
||||
public Dictionary<string, InSide>? InSim { get; set; }
|
||||
|
||||
/// <summary>True if this is an outbound gateway connection.</summary>
|
||||
public bool Outbound { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set in the read loop without locking to record that the inbound side
|
||||
/// sent its CONNECT protocol.
|
||||
/// </summary>
|
||||
public bool Connected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the remote server only understands the old <c>$GR.</c> prefix,
|
||||
/// not the newer <c>$GNR.</c> scheme.
|
||||
/// </summary>
|
||||
public bool UseOldPrefix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true the inbound side switches accounts to interest-only mode
|
||||
/// immediately, so the outbound side can disregard optimistic mode.
|
||||
/// </summary>
|
||||
public bool InterestOnlyMode { get; set; }
|
||||
|
||||
/// <summary>Name of the remote server on this gateway connection.</summary>
|
||||
public string RemoteName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outbound subject-interest entry for a single account on a gateway connection.
|
||||
/// Mirrors Go <c>outsie</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class OutSide
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
|
||||
|
||||
/// <summary>Current interest mode for this account on the outbound gateway.</summary>
|
||||
public GatewayInterestMode Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set of subjects for which the remote has signalled no-interest.
|
||||
/// Null when the remote has sent all its subscriptions (interest-only mode).
|
||||
/// </summary>
|
||||
public HashSet<string>? Ni { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subscription index: contains queue subs in optimistic mode,
|
||||
/// or all subs when <see cref="Mode"/> has been switched.
|
||||
/// </summary>
|
||||
public SubscriptionIndex? Sl { get; set; }
|
||||
|
||||
/// <summary>Number of queue subscriptions tracked in <see cref="Sl"/>.</summary>
|
||||
public int Qsubs { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lock helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void AcquireReadLock() => _lock.EnterReadLock();
|
||||
public void ReleaseReadLock() => _lock.ExitReadLock();
|
||||
public void AcquireWriteLock() => _lock.EnterWriteLock();
|
||||
public void ReleaseWriteLock() => _lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inbound subject-interest entry for a single account on a gateway connection.
|
||||
/// Tracks subjects for which an RS- was sent to the remote, and the current mode.
|
||||
/// Mirrors Go <c>insie</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class InSide
|
||||
{
|
||||
/// <summary>
|
||||
/// Subjects for which RS- was sent to the remote (null when in interest-only mode).
|
||||
/// </summary>
|
||||
public HashSet<string>? Ni { get; set; }
|
||||
|
||||
/// <summary>Current interest mode for this account on the inbound gateway.</summary>
|
||||
public GatewayInterestMode Mode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single gateway reply-mapping entry: the mapped subject and its expiry.
|
||||
/// Mirrors Go <c>gwReplyMap</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class GwReplyMap
|
||||
{
|
||||
/// <summary>The mapped (routed) subject string.</summary>
|
||||
public string Ms { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Expiry expressed as <see cref="DateTime.Ticks"/> (UTC).</summary>
|
||||
public long Exp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gateway reply routing table and a fast-path check flag.
|
||||
/// Mirrors Go <c>gwReplyMapping</c> struct in gateway.go.
|
||||
/// </summary>
|
||||
internal sealed class GwReplyMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-zero when the mapping table should be consulted while processing
|
||||
/// inbound messages. Accessed via <c>Interlocked</c> — must be 32-bit aligned.
|
||||
/// </summary>
|
||||
public int Check;
|
||||
|
||||
/// <summary>Active reply-subject → GwReplyMap entries.</summary>
|
||||
public Dictionary<string, GwReplyMap> Mapping { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the routed subject for <paramref name="subject"/> if a mapping
|
||||
/// exists, otherwise returns the original subject and <c>false</c>.
|
||||
/// Caller must hold any required lock before invoking.
|
||||
/// </summary>
|
||||
public (byte[] Subject, bool Found) Get(byte[] subject)
|
||||
{
|
||||
// TODO: session 16 — implement mapping lookup
|
||||
return (subject, false);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,21 @@ public sealed class StreamDeletionMeta
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the pending entry for <paramref name="seq"/>.
|
||||
/// </summary>
|
||||
public bool TryGetPending(ulong seq, out SdmBySeq entry) => _pending.TryGetValue(seq, out entry);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the pending entry for <paramref name="seq"/>.
|
||||
/// </summary>
|
||||
public void SetPending(ulong seq, SdmBySeq entry) => _pending[seq] = entry;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pending count for <paramref name="subj"/>, or 0 if not tracked.
|
||||
/// </summary>
|
||||
public ulong GetSubjectTotal(string subj) => _totals.TryGetValue(subj, out var cnt) ? cnt : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Clears all tracked data.
|
||||
/// Mirrors <c>SDMMeta.empty</c>.
|
||||
|
||||
@@ -1096,6 +1096,14 @@ public sealed class SubscriptionIndex
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write lock must be held.
|
||||
private Exception? AddInsertNotify(string subject, Action<bool> notify)
|
||||
=> AddNotify(_notify!.Insert, subject, notify);
|
||||
|
||||
// Write lock must be held.
|
||||
private Exception? AddRemoveNotify(string subject, Action<bool> notify)
|
||||
=> AddNotify(_notify!.Remove, subject, notify);
|
||||
|
||||
private static Exception? AddNotify(Dictionary<string, List<Action<bool>>> m, string subject, Action<bool> notify)
|
||||
{
|
||||
if (m.TryGetValue(subject, out var chs))
|
||||
@@ -1531,6 +1539,9 @@ public sealed class SubscriptionIndex
|
||||
public List<Subscription>? PList;
|
||||
public SublistLevel? Next;
|
||||
|
||||
/// <summary>Factory method matching Go's <c>newNode()</c>.</summary>
|
||||
public static SublistNode NewNode() => new();
|
||||
|
||||
public bool IsEmpty()
|
||||
{
|
||||
return PSubs.Count == 0 && (QSubs == null || QSubs.Count == 0) &&
|
||||
@@ -1544,6 +1555,9 @@ public sealed class SubscriptionIndex
|
||||
public SublistNode? Pwc;
|
||||
public SublistNode? Fwc;
|
||||
|
||||
/// <summary>Factory method matching Go's <c>newLevel()</c>.</summary>
|
||||
public static SublistLevel NewLevel() => new();
|
||||
|
||||
public int NumNodes()
|
||||
{
|
||||
var num = Nodes.Count;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// Adapted from server/log.go in the NATS server Go source.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
@@ -156,6 +157,53 @@ public sealed class ServerLogging
|
||||
var statement = string.Format(format, args);
|
||||
Warnf("{0}", statement);
|
||||
}
|
||||
|
||||
// ---- Trace sanitization ----
|
||||
// Mirrors removeSecretsFromTrace / redact in server/client.go.
|
||||
// passPat = `"?\s*pass\S*?"?\s*[:=]\s*"?(([^",\r\n}])*)` — captures the value of any pass/password field.
|
||||
// tokenPat = `"?\s*auth_token\S*?"?\s*[:=]\s*"?(([^",\r\n}])*)` — captures auth_token value.
|
||||
// Only the FIRST match is redacted (mirrors the Go break-after-first-match behaviour).
|
||||
|
||||
// Go: "?\s*pass\S*?"?\s*[:=]\s*"?(([^",\r\n}])*)
|
||||
private static readonly Regex s_passPattern = new(
|
||||
@"""?\s*pass\S*?""?\s*[:=]\s*""?(([^"",\r\n}])*)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Go: "?\s*auth_token\S*?"?\s*[:=]\s*"?(([^",\r\n}])*)
|
||||
private static readonly Regex s_authTokenPattern = new(
|
||||
@"""?\s*auth_token\S*?""?\s*[:=]\s*""?(([^"",\r\n}])*)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Removes passwords from a protocol trace string.
|
||||
/// Mirrors <c>removeSecretsFromTrace</c> in client.go (pass step).
|
||||
/// Only the first occurrence is redacted.
|
||||
/// </summary>
|
||||
public static string RemovePassFromTrace(string s)
|
||||
=> RedactFirst(s_passPattern, s);
|
||||
|
||||
/// <summary>
|
||||
/// Removes auth_token from a protocol trace string.
|
||||
/// Mirrors <c>removeSecretsFromTrace</c> in client.go (auth_token step).
|
||||
/// Only the first occurrence is redacted.
|
||||
/// </summary>
|
||||
public static string RemoveAuthTokenFromTrace(string s)
|
||||
=> RedactFirst(s_authTokenPattern, s);
|
||||
|
||||
/// <summary>
|
||||
/// Removes both passwords and auth tokens from a protocol trace string.
|
||||
/// Mirrors <c>removeSecretsFromTrace</c> in client.go.
|
||||
/// </summary>
|
||||
public static string RemoveSecretsFromTrace(string s)
|
||||
=> RemoveAuthTokenFromTrace(RemovePassFromTrace(s));
|
||||
|
||||
private static string RedactFirst(Regex pattern, string s)
|
||||
{
|
||||
var m = pattern.Match(s);
|
||||
if (!m.Success) return s;
|
||||
var cap = m.Groups[1]; // captured value substring
|
||||
return string.Concat(s.AsSpan(0, cap.Index), "[REDACTED]", s.AsSpan(cap.Index + cap.Length));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
@@ -25,7 +26,16 @@ namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
/// </summary>
|
||||
public static class SignalHandler
|
||||
{
|
||||
private const string ResolvePidError = "unable to resolve pid, try providing one";
|
||||
private static string _processName = "nats-server";
|
||||
internal static Func<List<int>> ResolvePidsHandler { get; set; } = ResolvePids;
|
||||
internal static Func<int, UnixSignal, Exception?> SendSignalHandler { get; set; } = SendSignal;
|
||||
|
||||
internal static void ResetTestHooks()
|
||||
{
|
||||
ResolvePidsHandler = ResolvePids;
|
||||
SendSignalHandler = SendSignal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the process name used for resolving PIDs.
|
||||
@@ -46,25 +56,67 @@ public static class SignalHandler
|
||||
|
||||
try
|
||||
{
|
||||
List<int> pids;
|
||||
if (string.IsNullOrEmpty(pidExpr))
|
||||
var pids = new List<int>(1);
|
||||
var pidStr = pidExpr.TrimEnd('*');
|
||||
var isGlob = pidExpr.EndsWith('*');
|
||||
|
||||
if (!string.IsNullOrEmpty(pidStr))
|
||||
{
|
||||
pids = ResolvePids();
|
||||
if (pids.Count == 0)
|
||||
return new InvalidOperationException("no nats-server processes found");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (int.TryParse(pidExpr, out var pid))
|
||||
pids = [pid];
|
||||
else
|
||||
return new InvalidOperationException($"invalid pid: {pidExpr}");
|
||||
if (!int.TryParse(pidStr, out var pid))
|
||||
return new InvalidOperationException($"invalid pid: {pidStr}");
|
||||
pids.Add(pid);
|
||||
}
|
||||
|
||||
var signal = CommandToUnixSignal(command);
|
||||
if (string.IsNullOrEmpty(pidStr) || isGlob)
|
||||
pids = ResolvePidsHandler();
|
||||
|
||||
if (pids.Count > 1 && !isGlob)
|
||||
{
|
||||
var sb = new StringBuilder($"multiple {_processName} processes running:");
|
||||
foreach (var p in pids)
|
||||
sb.Append('\n').Append(p);
|
||||
return new InvalidOperationException(sb.ToString());
|
||||
}
|
||||
|
||||
if (pids.Count == 0)
|
||||
return new InvalidOperationException($"no {_processName} processes running");
|
||||
|
||||
UnixSignal signal;
|
||||
try
|
||||
{
|
||||
signal = CommandToUnixSignal(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
|
||||
var errBuilder = new StringBuilder();
|
||||
foreach (var pid in pids)
|
||||
Process.GetProcessById(pid).Kill(signal == UnixSignal.SigKill);
|
||||
{
|
||||
var pidText = pid.ToString();
|
||||
if (pidStr.Length > 0 && pidText != pidStr)
|
||||
{
|
||||
if (!isGlob || !pidText.StartsWith(pidStr, StringComparison.Ordinal))
|
||||
continue;
|
||||
}
|
||||
|
||||
var err = SendSignalHandler(pid, signal);
|
||||
if (err != null)
|
||||
{
|
||||
errBuilder
|
||||
.Append('\n')
|
||||
.Append("signal \"")
|
||||
.Append(CommandToString(command))
|
||||
.Append("\" ")
|
||||
.Append(pid)
|
||||
.Append(": ")
|
||||
.Append(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (errBuilder.Length > 0)
|
||||
return new InvalidOperationException(errBuilder.ToString());
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -80,7 +132,7 @@ public static class SignalHandler
|
||||
/// </summary>
|
||||
public static List<int> ResolvePids()
|
||||
{
|
||||
var pids = new List<int>();
|
||||
var pids = new List<int>(8);
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("pgrep", _processName)
|
||||
@@ -90,22 +142,33 @@ public static class SignalHandler
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return pids;
|
||||
if (proc == null)
|
||||
throw new InvalidOperationException(ResolvePidError);
|
||||
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
if (proc.ExitCode != 0)
|
||||
return pids;
|
||||
|
||||
var currentPid = Environment.ProcessId;
|
||||
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (int.TryParse(line.Trim(), out var pid) && pid != currentPid)
|
||||
if (!int.TryParse(line.Trim(), out var pid))
|
||||
throw new InvalidOperationException(ResolvePidError);
|
||||
|
||||
if (pid != currentPid)
|
||||
pids.Add(pid);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message == ResolvePidError)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// pgrep not available or failed
|
||||
throw new InvalidOperationException(ResolvePidError);
|
||||
}
|
||||
|
||||
return pids;
|
||||
}
|
||||
|
||||
@@ -119,7 +182,33 @@ public static class SignalHandler
|
||||
ServerCommand.Quit => UnixSignal.SigInt,
|
||||
ServerCommand.Reopen => UnixSignal.SigUsr1,
|
||||
ServerCommand.Reload => UnixSignal.SigHup,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(command), $"unknown command: {command}"),
|
||||
ServerCommand.LameDuckMode => UnixSignal.SigUsr2,
|
||||
ServerCommand.Term => UnixSignal.SigTerm,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(command), $"unknown signal \"{CommandToString(command)}\""),
|
||||
};
|
||||
|
||||
private static Exception? SendSignal(int pid, UnixSignal signal)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.GetProcessById(pid).Kill(signal == UnixSignal.SigKill);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
|
||||
private static string CommandToString(ServerCommand command) => command switch
|
||||
{
|
||||
ServerCommand.Stop => "stop",
|
||||
ServerCommand.Quit => "quit",
|
||||
ServerCommand.Reopen => "reopen",
|
||||
ServerCommand.Reload => "reload",
|
||||
ServerCommand.LameDuckMode => "ldm",
|
||||
ServerCommand.Term => "term",
|
||||
_ => command.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -243,6 +243,51 @@ public sealed class SubjectTransform : ISubjectTransformer
|
||||
public static (SubjectTransform? transform, Exception? err) NewStrict(string src, string dest) =>
|
||||
NewWithStrict(src, dest, true);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a subject mapping destination. Checks each token for valid syntax,
|
||||
/// validates mustache-style mapping functions against known regexes, then verifies
|
||||
/// the full transform can be created. Mirrors Go's <c>ValidateMapping</c>.
|
||||
/// </summary>
|
||||
public static Exception? ValidateMapping(string src, string dest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dest))
|
||||
return null;
|
||||
|
||||
bool sfwc = false;
|
||||
foreach (var t in dest.Split(SubjectTokens.Btsep))
|
||||
{
|
||||
var length = t.Length;
|
||||
if (length == 0 || sfwc)
|
||||
return new MappingDestinationException(t, ServerErrors.ErrInvalidMappingDestinationSubject);
|
||||
|
||||
// If it looks like a mapping function, validate against known patterns.
|
||||
if (length > 4 && t[0] == '{' && t[1] == '{' && t[length - 2] == '}' && t[length - 1] == '}')
|
||||
{
|
||||
if (!PartitionRe.IsMatch(t) &&
|
||||
!WildcardRe.IsMatch(t) &&
|
||||
!SplitFromLeftRe.IsMatch(t) &&
|
||||
!SplitFromRightRe.IsMatch(t) &&
|
||||
!SliceFromLeftRe.IsMatch(t) &&
|
||||
!SliceFromRightRe.IsMatch(t) &&
|
||||
!SplitRe.IsMatch(t) &&
|
||||
!RandomRe.IsMatch(t))
|
||||
{
|
||||
return new MappingDestinationException(t, ServerErrors.ErrUnknownMappingDestinationFunction);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (length == 1 && t[0] == SubjectTokens.Fwc)
|
||||
sfwc = true;
|
||||
else if (t.AsSpan().ContainsAny("\t\n\f\r "))
|
||||
return ServerErrors.ErrInvalidMappingDestinationSubject;
|
||||
}
|
||||
|
||||
// Verify that the transform can actually be created.
|
||||
var (_, err) = New(src, dest);
|
||||
return err;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a published subject against the source pattern.
|
||||
/// Returns the transformed subject or an error.
|
||||
|
||||
64
dotnet/src/ZB.MOM.NatsNet.Server/Internal/WaitGroup.cs
Normal file
64
dotnet/src/ZB.MOM.NatsNet.Server/Internal/WaitGroup.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// A Go-like WaitGroup: tracks a set of in-flight operations and lets callers
|
||||
/// block until all of them complete.
|
||||
/// </summary>
|
||||
internal sealed class WaitGroup
|
||||
{
|
||||
private int _count;
|
||||
private volatile TaskCompletionSource<bool> _tcs;
|
||||
|
||||
public WaitGroup()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_tcs.SetResult(true); // starts at zero, so "done" immediately
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increment the counter by <paramref name="delta"/> (usually 1).
|
||||
/// Must be called before starting the goroutine it tracks.
|
||||
/// </summary>
|
||||
public void Add(int delta = 1)
|
||||
{
|
||||
var newCount = Interlocked.Add(ref _count, delta);
|
||||
if (newCount < 0)
|
||||
throw new InvalidOperationException("WaitGroup counter went negative");
|
||||
|
||||
if (newCount == 0)
|
||||
{
|
||||
// All goroutines done — signal any waiters.
|
||||
Volatile.Read(ref _tcs).TrySetResult(true);
|
||||
}
|
||||
else if (delta > 0 && newCount == delta)
|
||||
{
|
||||
// Transitioning from 0 to positive — replace the completed TCS
|
||||
// with a fresh unsignaled one so Wait() will block correctly.
|
||||
Volatile.Write(ref _tcs,
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decrement the counter by 1. Called when a goroutine finishes.</summary>
|
||||
public void Done() => Add(-1);
|
||||
|
||||
/// <summary>Block synchronously until the counter reaches 0.</summary>
|
||||
public void Wait()
|
||||
{
|
||||
if (Volatile.Read(ref _count) == 0) return;
|
||||
Volatile.Read(ref _tcs).Task.GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
400
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/ConsumerMemStore.cs
Normal file
400
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/ConsumerMemStore.cs
Normal file
@@ -0,0 +1,400 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/memstore.go (consumerMemStore)
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IConsumerStore"/>.
|
||||
/// Stores consumer delivery and ack state in memory only.
|
||||
/// </summary>
|
||||
public sealed class ConsumerMemStore : IConsumerStore
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Fields
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private readonly object _mu = new();
|
||||
private readonly JetStreamMemStore _ms;
|
||||
private ConsumerConfig _cfg;
|
||||
private ConsumerState _state = new();
|
||||
private bool _closed;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructor
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new consumer memory store backed by the given stream store.
|
||||
/// </summary>
|
||||
public ConsumerMemStore(JetStreamMemStore ms, ConsumerConfig cfg)
|
||||
{
|
||||
_ms = ms;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — starting sequence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetStarting(ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_state.Delivered.Stream = sseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateStarting(ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (sseq > _state.Delivered.Stream)
|
||||
{
|
||||
_state.Delivered.Stream = sseq;
|
||||
// For AckNone just update delivered and ackfloor at the same time.
|
||||
if (_cfg.AckPolicy == AckPolicy.AckNone)
|
||||
_state.AckFloor.Stream = sseq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Reset(ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_state = new ConsumerState();
|
||||
}
|
||||
SetStarting(sseq);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — state query
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool HasState()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
return _state.Delivered.Consumer != 0 || _state.Delivered.Stream != 0;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — delivery tracking
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateDelivered(ulong dseq, ulong sseq, ulong dc, long ts)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (dc != 1 && _cfg.AckPolicy == AckPolicy.AckNone)
|
||||
throw StoreErrors.ErrNoAckPolicy;
|
||||
|
||||
// Replay from old leader — ignore outdated updates.
|
||||
if (dseq <= _state.AckFloor.Consumer)
|
||||
return;
|
||||
|
||||
if (_cfg.AckPolicy != AckPolicy.AckNone)
|
||||
{
|
||||
_state.Pending ??= new Dictionary<ulong, Pending>();
|
||||
|
||||
if (sseq <= _state.Delivered.Stream)
|
||||
{
|
||||
// Update to a previously delivered message.
|
||||
if (_state.Pending.TryGetValue(sseq, out var p) && p != null)
|
||||
p.Timestamp = ts;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.Pending[sseq] = new Pending { Sequence = dseq, Timestamp = ts };
|
||||
}
|
||||
|
||||
if (dseq > _state.Delivered.Consumer)
|
||||
_state.Delivered.Consumer = dseq;
|
||||
if (sseq > _state.Delivered.Stream)
|
||||
_state.Delivered.Stream = sseq;
|
||||
|
||||
if (dc > 1)
|
||||
{
|
||||
var maxdc = (ulong)_cfg.MaxDeliver;
|
||||
if (maxdc > 0 && dc > maxdc)
|
||||
_state.Pending.Remove(sseq);
|
||||
|
||||
_state.Redelivered ??= new Dictionary<ulong, ulong>();
|
||||
if (!_state.Redelivered.TryGetValue(sseq, out var cur) || cur < dc - 1)
|
||||
_state.Redelivered[sseq] = dc - 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// AckNone — update delivered and ackfloor together.
|
||||
if (dseq > _state.Delivered.Consumer)
|
||||
{
|
||||
_state.Delivered.Consumer = dseq;
|
||||
_state.AckFloor.Consumer = dseq;
|
||||
}
|
||||
if (sseq > _state.Delivered.Stream)
|
||||
{
|
||||
_state.Delivered.Stream = sseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateAcks(ulong dseq, ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_cfg.AckPolicy == AckPolicy.AckNone)
|
||||
throw StoreErrors.ErrNoAckPolicy;
|
||||
|
||||
// Ignore outdated acks.
|
||||
if (dseq <= _state.AckFloor.Consumer)
|
||||
return;
|
||||
|
||||
if (_state.Pending == null || !_state.Pending.ContainsKey(sseq))
|
||||
{
|
||||
_state.Redelivered?.Remove(sseq);
|
||||
throw StoreErrors.ErrStoreMsgNotFound;
|
||||
}
|
||||
|
||||
if (_cfg.AckPolicy == AckPolicy.AckAll)
|
||||
{
|
||||
var sgap = sseq - _state.AckFloor.Stream;
|
||||
_state.AckFloor.Consumer = dseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
|
||||
if (sgap > (ulong)_state.Pending.Count)
|
||||
{
|
||||
var toRemove = new List<ulong>();
|
||||
foreach (var kv in _state.Pending)
|
||||
if (kv.Key <= sseq)
|
||||
toRemove.Add(kv.Key);
|
||||
foreach (var k in toRemove)
|
||||
{
|
||||
_state.Pending.Remove(k);
|
||||
_state.Redelivered?.Remove(k);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var seq = sseq; seq > sseq - sgap && _state.Pending.Count > 0; seq--)
|
||||
{
|
||||
_state.Pending.Remove(seq);
|
||||
_state.Redelivered?.Remove(seq);
|
||||
if (seq == 0) break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AckExplicit
|
||||
if (_state.Pending.TryGetValue(sseq, out var pending) && pending != null)
|
||||
{
|
||||
_state.Pending.Remove(sseq);
|
||||
if (dseq > pending.Sequence && pending.Sequence > 0)
|
||||
dseq = pending.Sequence; // Use the original delivery sequence.
|
||||
}
|
||||
|
||||
if (_state.Pending.Count == 0)
|
||||
{
|
||||
_state.AckFloor.Consumer = _state.Delivered.Consumer;
|
||||
_state.AckFloor.Stream = _state.Delivered.Stream;
|
||||
}
|
||||
else if (dseq == _state.AckFloor.Consumer + 1)
|
||||
{
|
||||
_state.AckFloor.Consumer = dseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
|
||||
if (_state.Delivered.Consumer > dseq)
|
||||
{
|
||||
for (var ss = sseq + 1; ss <= _state.Delivered.Stream; ss++)
|
||||
{
|
||||
if (_state.Pending.TryGetValue(ss, out var pp) && pp != null)
|
||||
{
|
||||
if (pp.Sequence > 0)
|
||||
{
|
||||
_state.AckFloor.Consumer = pp.Sequence - 1;
|
||||
_state.AckFloor.Stream = ss - 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state.Redelivered?.Remove(sseq);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — config update
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateConfig(ConsumerConfig cfg)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_cfg = cfg;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — update state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Update(ConsumerState state)
|
||||
{
|
||||
if (state.AckFloor.Consumer > state.Delivered.Consumer)
|
||||
throw new InvalidOperationException("bad ack floor for consumer");
|
||||
if (state.AckFloor.Stream > state.Delivered.Stream)
|
||||
throw new InvalidOperationException("bad ack floor for stream");
|
||||
|
||||
Dictionary<ulong, Pending>? pending = null;
|
||||
Dictionary<ulong, ulong>? redelivered = null;
|
||||
|
||||
if (state.Pending?.Count > 0)
|
||||
{
|
||||
pending = new Dictionary<ulong, Pending>(state.Pending.Count);
|
||||
foreach (var kv in state.Pending)
|
||||
{
|
||||
if (kv.Key <= state.AckFloor.Stream || kv.Key > state.Delivered.Stream)
|
||||
throw new InvalidOperationException($"bad pending entry, sequence [{kv.Key}] out of range");
|
||||
pending[kv.Key] = new Pending { Sequence = kv.Value.Sequence, Timestamp = kv.Value.Timestamp };
|
||||
}
|
||||
}
|
||||
|
||||
if (state.Redelivered?.Count > 0)
|
||||
{
|
||||
redelivered = new Dictionary<ulong, ulong>(state.Redelivered);
|
||||
}
|
||||
|
||||
lock (_mu)
|
||||
{
|
||||
// Ignore outdated updates.
|
||||
if (state.Delivered.Consumer < _state.Delivered.Consumer ||
|
||||
state.AckFloor.Stream < _state.AckFloor.Stream)
|
||||
throw new InvalidOperationException("old update ignored");
|
||||
|
||||
_state.Delivered = new SequencePair { Consumer = state.Delivered.Consumer, Stream = state.Delivered.Stream };
|
||||
_state.AckFloor = new SequencePair { Consumer = state.AckFloor.Consumer, Stream = state.AckFloor.Stream };
|
||||
_state.Pending = pending;
|
||||
_state.Redelivered = redelivered;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — state retrieval
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ConsumerState? State, Exception? Error) State() => StateWithCopy(doCopy: true);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ConsumerState? State, Exception? Error) BorrowState() => StateWithCopy(doCopy: false);
|
||||
|
||||
private (ConsumerState? State, Exception? Error) StateWithCopy(bool doCopy)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
return (null, StoreErrors.ErrStoreClosed);
|
||||
|
||||
var state = new ConsumerState
|
||||
{
|
||||
Delivered = new SequencePair { Consumer = _state.Delivered.Consumer, Stream = _state.Delivered.Stream },
|
||||
AckFloor = new SequencePair { Consumer = _state.AckFloor.Consumer, Stream = _state.AckFloor.Stream },
|
||||
};
|
||||
|
||||
if (_state.Pending?.Count > 0)
|
||||
{
|
||||
state.Pending = doCopy ? CopyPending() : _state.Pending;
|
||||
}
|
||||
|
||||
if (_state.Redelivered?.Count > 0)
|
||||
{
|
||||
state.Redelivered = doCopy ? CopyRedelivered() : _state.Redelivered;
|
||||
}
|
||||
|
||||
return (state, null);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — encoding
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public byte[] EncodedState()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
throw StoreErrors.ErrStoreClosed;
|
||||
// TODO: session 17 — encode consumer state to binary
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IConsumerStore — lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StorageType Type() => StorageType.MemoryStorage;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Stop()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_closed = true;
|
||||
}
|
||||
_ms.RemoveConsumer(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete() => Stop();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StreamDelete() => Stop();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Dictionary<ulong, Pending> CopyPending()
|
||||
{
|
||||
var pending = new Dictionary<ulong, Pending>(_state.Pending!.Count);
|
||||
foreach (var kv in _state.Pending!)
|
||||
pending[kv.Key] = new Pending { Sequence = kv.Value.Sequence, Timestamp = kv.Value.Timestamp };
|
||||
return pending;
|
||||
}
|
||||
|
||||
private Dictionary<ulong, ulong> CopyRedelivered()
|
||||
{
|
||||
return new Dictionary<ulong, ulong>(_state.Redelivered!);
|
||||
}
|
||||
}
|
||||
413
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStore.cs
Normal file
413
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStore.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/filestore.go (fileStore struct and methods)
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// File-backed implementation of <see cref="IStreamStore"/>.
|
||||
/// Stores JetStream messages in per-block files on disk with optional
|
||||
/// encryption and compression.
|
||||
/// Mirrors the <c>fileStore</c> struct in filestore.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamFileStore : IStreamStore, IDisposable
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Fields — mirrors fileStore struct fields
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.NoRecursion);
|
||||
|
||||
// State
|
||||
private StreamState _state = new();
|
||||
private List<ulong>? _tombs;
|
||||
private LostStreamData? _ld;
|
||||
|
||||
// Callbacks
|
||||
private StorageUpdateHandler? _scb;
|
||||
private StorageRemoveMsgHandler? _rmcb;
|
||||
private ProcessJetStreamMsgHandler? _pmsgcb;
|
||||
|
||||
// Age-check timer
|
||||
private Timer? _ageChk;
|
||||
private bool _ageChkRun;
|
||||
private long _ageChkTime;
|
||||
|
||||
// Background sync timer
|
||||
private Timer? _syncTmr;
|
||||
|
||||
// Configuration
|
||||
private FileStreamInfo _cfg;
|
||||
private FileStoreConfig _fcfg;
|
||||
|
||||
// Message block list and index
|
||||
private MessageBlock? _lmb; // last (active write) block
|
||||
private List<MessageBlock> _blks = [];
|
||||
private Dictionary<uint, MessageBlock> _bim = [];
|
||||
|
||||
// Per-subject index map
|
||||
private SubjectTree<Psi>? _psim;
|
||||
|
||||
// Total subject-list length (sum of subject-string lengths)
|
||||
private int _tsl;
|
||||
|
||||
// writeFullState concurrency guard
|
||||
private readonly object _wfsmu = new();
|
||||
private long _wfsrun; // Interlocked: is writeFullState running?
|
||||
private int _wfsadml; // Average dmap length (protected by _wfsmu)
|
||||
|
||||
// Quit / load-done channels (Channel<byte> mimics chan struct{})
|
||||
private Channel<byte>? _qch;
|
||||
private Channel<byte>? _fsld;
|
||||
|
||||
// Consumer list
|
||||
private readonly ReaderWriterLockSlim _cmu = new(LockRecursionPolicy.NoRecursion);
|
||||
private List<IConsumerStore> _cfs = [];
|
||||
|
||||
// Snapshot-in-progress count
|
||||
private int _sips;
|
||||
|
||||
// Dirty-write counter (incremented when writes are pending flush)
|
||||
private int _dirty;
|
||||
|
||||
// Lifecycle flags
|
||||
private bool _closing;
|
||||
private volatile bool _closed;
|
||||
|
||||
// Flush-in-progress flag
|
||||
private bool _fip;
|
||||
|
||||
// Whether the store has ever received a message
|
||||
private bool _receivedAny;
|
||||
|
||||
// Whether the first sequence has been moved forward
|
||||
private bool _firstMoved;
|
||||
|
||||
// Last PurgeEx call time (for throttle logic)
|
||||
private DateTime _lpex;
|
||||
|
||||
// In this incremental port stage, file-store logic delegates core stream semantics
|
||||
// to the memory store implementation while file-specific APIs are added on top.
|
||||
private readonly JetStreamMemStore _memStore;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructor
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a file-backed stream store using the supplied file-store
|
||||
/// configuration and stream information.
|
||||
/// </summary>
|
||||
/// <param name="fcfg">File-store configuration (block size, cipher, paths, etc.).</param>
|
||||
/// <param name="cfg">Stream metadata (created time and stream config).</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="fcfg"/> or <paramref name="cfg"/> is null.
|
||||
/// </exception>
|
||||
public JetStreamFileStore(FileStoreConfig fcfg, FileStreamInfo cfg)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fcfg);
|
||||
ArgumentNullException.ThrowIfNull(cfg);
|
||||
|
||||
_fcfg = fcfg;
|
||||
_cfg = cfg;
|
||||
|
||||
// Apply defaults (mirrors newFileStoreWithCreated in filestore.go).
|
||||
if (_fcfg.BlockSize == 0)
|
||||
_fcfg.BlockSize = FileStoreDefaults.DefaultLargeBlockSize;
|
||||
if (_fcfg.CacheExpire == TimeSpan.Zero)
|
||||
_fcfg.CacheExpire = FileStoreDefaults.DefaultCacheBufferExpiration;
|
||||
if (_fcfg.SubjectStateExpire == TimeSpan.Zero)
|
||||
_fcfg.SubjectStateExpire = FileStoreDefaults.DefaultFssExpiration;
|
||||
if (_fcfg.SyncInterval == TimeSpan.Zero)
|
||||
_fcfg.SyncInterval = FileStoreDefaults.DefaultSyncInterval;
|
||||
|
||||
_psim = new SubjectTree<Psi>();
|
||||
_bim = new Dictionary<uint, MessageBlock>();
|
||||
_qch = Channel.CreateUnbounded<byte>();
|
||||
_fsld = Channel.CreateUnbounded<byte>();
|
||||
|
||||
var memCfg = cfg.Config.Clone();
|
||||
memCfg.Storage = StorageType.MemoryStorage;
|
||||
_memStore = new JetStreamMemStore(memCfg);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — type / state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StorageType Type() => StorageType.FileStorage;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StreamState State()
|
||||
=> _memStore.State();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void FastState(StreamState state)
|
||||
=> _memStore.FastState(state);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — callback registration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RegisterStorageUpdates(StorageUpdateHandler cb)
|
||||
=> _memStore.RegisterStorageUpdates(cb);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RegisterStorageRemoveMsg(StorageRemoveMsgHandler cb)
|
||||
=> _memStore.RegisterStorageRemoveMsg(cb);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RegisterProcessJetStreamMsg(ProcessJetStreamMsgHandler cb)
|
||||
=> _memStore.RegisterProcessJetStreamMsg(cb);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Stop()
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
|
||||
_ageChk?.Dispose();
|
||||
_ageChk = null;
|
||||
_syncTmr?.Dispose();
|
||||
_syncTmr = null;
|
||||
|
||||
_closed = true;
|
||||
_memStore.Stop();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — store / load (all stubs)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Seq, long Ts) StoreMsg(string subject, byte[]? hdr, byte[]? msg, long ttl)
|
||||
=> _memStore.StoreMsg(subject, hdr, msg, ttl);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StoreRawMsg(string subject, byte[]? hdr, byte[]? msg, ulong seq, long ts, long ttl, bool discardNewCheck)
|
||||
=> _memStore.StoreRawMsg(subject, hdr, msg, seq, ts, ttl, discardNewCheck);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Seq, Exception? Error) SkipMsg(ulong seq)
|
||||
=> _memStore.SkipMsg(seq);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SkipMsgs(ulong seq, ulong num)
|
||||
=> _memStore.SkipMsgs(seq, num);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void FlushAllPending()
|
||||
=> _memStore.FlushAllPending();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StoreMsg? LoadMsg(ulong seq, StoreMsg? sm)
|
||||
=> _memStore.LoadMsg(seq, sm);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (StoreMsg? Sm, ulong Skip) LoadNextMsg(string filter, bool wc, ulong start, StoreMsg? smp)
|
||||
=> _memStore.LoadNextMsg(filter, wc, start, smp);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (StoreMsg? Sm, ulong Skip) LoadNextMsgMulti(object? sl, ulong start, StoreMsg? smp)
|
||||
=> _memStore.LoadNextMsgMulti(sl, start, smp);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StoreMsg? LoadLastMsg(string subject, StoreMsg? sm)
|
||||
=> _memStore.LoadLastMsg(subject, sm);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (StoreMsg? Sm, Exception? Error) LoadPrevMsg(ulong start, StoreMsg? smp)
|
||||
=> _memStore.LoadPrevMsg(start, smp);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (StoreMsg? Sm, ulong Skip, Exception? Error) LoadPrevMsgMulti(object? sl, ulong start, StoreMsg? smp)
|
||||
=> _memStore.LoadPrevMsgMulti(sl, start, smp);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (bool Removed, Exception? Error) RemoveMsg(ulong seq)
|
||||
=> _memStore.RemoveMsg(seq);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (bool Removed, Exception? Error) EraseMsg(ulong seq)
|
||||
=> _memStore.EraseMsg(seq);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Purged, Exception? Error) Purge()
|
||||
=> _memStore.Purge();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Purged, Exception? Error) PurgeEx(string subject, ulong seq, ulong keep)
|
||||
=> _memStore.PurgeEx(subject, seq, keep);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Purged, Exception? Error) Compact(ulong seq)
|
||||
=> _memStore.Compact(seq);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Truncate(ulong seq)
|
||||
=> _memStore.Truncate(seq);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — query methods (all stubs)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ulong GetSeqFromTime(DateTime t)
|
||||
=> _memStore.GetSeqFromTime(t);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SimpleState FilteredState(ulong seq, string subject)
|
||||
=> _memStore.FilteredState(seq, subject);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<string, SimpleState> SubjectsState(string filterSubject)
|
||||
=> _memStore.SubjectsState(filterSubject);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Dictionary<string, ulong> SubjectsTotals(string filterSubject)
|
||||
=> _memStore.SubjectsTotals(filterSubject);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong[] Seqs, Exception? Error) AllLastSeqs()
|
||||
=> _memStore.AllLastSeqs();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong[] Seqs, Exception? Error) MultiLastSeqs(string[] filters, ulong maxSeq, int maxAllowed)
|
||||
=> _memStore.MultiLastSeqs(filters, maxSeq, maxAllowed);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (string Subject, Exception? Error) SubjectForSeq(ulong seq)
|
||||
=> _memStore.SubjectForSeq(seq);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Total, ulong ValidThrough, Exception? Error) NumPending(ulong sseq, string filter, bool lastPerSubject)
|
||||
=> _memStore.NumPending(sseq, filter, lastPerSubject);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Total, ulong ValidThrough, Exception? Error) NumPendingMulti(ulong sseq, object? sl, bool lastPerSubject)
|
||||
=> _memStore.NumPendingMulti(sseq, sl, lastPerSubject);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — stream state encoding (stubs)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (byte[] Enc, Exception? Error) EncodedStreamState(ulong failed)
|
||||
=> _memStore.EncodedStreamState(failed);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SyncDeleted(DeleteBlocks dbs)
|
||||
=> _memStore.SyncDeleted(dbs);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — config / admin (stubs)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateConfig(StreamConfig cfg)
|
||||
{
|
||||
_cfg.Config = cfg.Clone();
|
||||
_memStore.UpdateConfig(cfg);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(bool inline)
|
||||
=> _memStore.Delete(inline);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ResetState()
|
||||
=> _memStore.ResetState();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — consumer management (stubs)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IConsumerStore ConsumerStore(string name, DateTime created, ConsumerConfig cfg)
|
||||
{
|
||||
var cfi = new FileConsumerInfo
|
||||
{
|
||||
Name = name,
|
||||
Created = created,
|
||||
Config = cfg,
|
||||
};
|
||||
var odir = Path.Combine(_fcfg.StoreDir, FileStoreDefaults.ConsumerDir, name);
|
||||
Directory.CreateDirectory(odir);
|
||||
var cs = new ConsumerFileStore(this, cfi, name, odir);
|
||||
AddConsumer(cs);
|
||||
return cs;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddConsumer(IConsumerStore o)
|
||||
{
|
||||
_cmu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_cfs.Add(o);
|
||||
_memStore.AddConsumer(o);
|
||||
}
|
||||
finally { _cmu.ExitWriteLock(); }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RemoveConsumer(IConsumerStore o)
|
||||
{
|
||||
_cmu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_cfs.Remove(o);
|
||||
_memStore.RemoveConsumer(o);
|
||||
}
|
||||
finally { _cmu.ExitWriteLock(); }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IStreamStore — snapshot / utilization (stubs)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (SnapshotResult? Result, Exception? Error) Snapshot(TimeSpan deadline, bool includeConsumers, bool checkMsgs)
|
||||
{
|
||||
var state = _memStore.State();
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(state);
|
||||
var reader = new MemoryStream(payload, writable: false);
|
||||
return (new SnapshotResult { Reader = reader, State = state }, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ulong Total, ulong Reported, Exception? Error) Utilization()
|
||||
=> _memStore.Utilization();
|
||||
}
|
||||
473
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStoreTypes.cs
Normal file
473
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStoreTypes.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/filestore.go
|
||||
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileStoreConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a file-backed JetStream stream store.
|
||||
/// Mirrors <c>FileStoreConfig</c> in filestore.go.
|
||||
/// </summary>
|
||||
public sealed class FileStoreConfig
|
||||
{
|
||||
/// <summary>Parent directory for all storage.</summary>
|
||||
public string StoreDir { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// File block size. Also represents the maximum per-block overhead.
|
||||
/// Defaults to <see cref="FileStoreDefaults.DefaultBlockSize"/>.
|
||||
/// </summary>
|
||||
public ulong BlockSize { get; set; }
|
||||
|
||||
/// <summary>How long with no activity until the in-memory cache is expired.</summary>
|
||||
public TimeSpan CacheExpire { get; set; }
|
||||
|
||||
/// <summary>How long with no activity until a message block's subject state is expired.</summary>
|
||||
public TimeSpan SubjectStateExpire { get; set; }
|
||||
|
||||
/// <summary>How often the store syncs data to disk in the background.</summary>
|
||||
public TimeSpan SyncInterval { get; set; }
|
||||
|
||||
/// <summary>When true, every write is immediately synced to disk.</summary>
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
/// <summary>When true, write operations may be batched and flushed asynchronously.</summary>
|
||||
public bool AsyncFlush { get; set; }
|
||||
|
||||
/// <summary>Encryption cipher used when encrypting blocks.</summary>
|
||||
public StoreCipher Cipher { get; set; }
|
||||
|
||||
/// <summary>Compression algorithm applied to stored blocks.</summary>
|
||||
public StoreCompression Compression { get; set; }
|
||||
|
||||
// Internal reference to the owning server — not serialised.
|
||||
// Equivalent to srv *Server in Go; kept as object to avoid circular project deps.
|
||||
internal object? Server { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileStreamInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the creation time alongside the stream configuration.
|
||||
/// Mirrors <c>FileStreamInfo</c> in filestore.go.
|
||||
/// </summary>
|
||||
public sealed class FileStreamInfo
|
||||
{
|
||||
/// <summary>UTC time at which the stream was created.</summary>
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
/// <summary>Stream configuration.</summary>
|
||||
public StreamConfig Config { get; set; } = new();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileConsumerInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Used for creating and restoring consumer stores from disk.
|
||||
/// Mirrors <c>FileConsumerInfo</c> in filestore.go.
|
||||
/// </summary>
|
||||
public sealed class FileConsumerInfo
|
||||
{
|
||||
/// <summary>UTC time at which the consumer was created.</summary>
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
/// <summary>Durable consumer name.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Consumer configuration.</summary>
|
||||
public ConsumerConfig Config { get; set; } = new();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Psi — per-subject index entry (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Per-subject index entry stored in the subject tree.
|
||||
/// Mirrors the <c>psi</c> struct in filestore.go.
|
||||
/// </summary>
|
||||
internal sealed class Psi
|
||||
{
|
||||
/// <summary>Total messages for this subject across all blocks.</summary>
|
||||
public ulong Total { get; set; }
|
||||
|
||||
/// <summary>Index of the first block that holds messages for this subject.</summary>
|
||||
public uint Fblk { get; set; }
|
||||
|
||||
/// <summary>Index of the last block that holds messages for this subject.</summary>
|
||||
public uint Lblk { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache — write-through and load cache (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Write-through caching layer also used when loading messages from disk.
|
||||
/// Mirrors the <c>cache</c> struct in filestore.go.
|
||||
/// </summary>
|
||||
internal sealed class Cache
|
||||
{
|
||||
/// <summary>Raw message data buffer.</summary>
|
||||
public byte[] Buf { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Write position into <see cref="Buf"/>.</summary>
|
||||
public int Wp { get; set; }
|
||||
|
||||
/// <summary>Per-sequence byte offsets into <see cref="Buf"/>.</summary>
|
||||
public uint[] Idx { get; set; } = Array.Empty<uint>();
|
||||
|
||||
/// <summary>First sequence number this cache covers.</summary>
|
||||
public ulong Fseq { get; set; }
|
||||
|
||||
/// <summary>No-random-access flag: when true sequential access is assumed.</summary>
|
||||
public bool Nra { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MsgId — sequence + timestamp pair (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Pairs a message sequence number with its nanosecond timestamp.
|
||||
/// Mirrors the <c>msgId</c> struct in filestore.go.
|
||||
/// </summary>
|
||||
internal struct MsgId
|
||||
{
|
||||
/// <summary>Sequence number.</summary>
|
||||
public ulong Seq;
|
||||
|
||||
/// <summary>Nanosecond Unix timestamp.</summary>
|
||||
public long Ts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CompressionInfo — compression metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Compression metadata attached to a message block.
|
||||
/// Mirrors <c>CompressionInfo</c> in filestore.go.
|
||||
/// </summary>
|
||||
public sealed class CompressionInfo
|
||||
{
|
||||
/// <summary>Compression algorithm in use.</summary>
|
||||
public StoreCompression Type { get; set; }
|
||||
|
||||
/// <summary>Original (uncompressed) size in bytes.</summary>
|
||||
public ulong Original { get; set; }
|
||||
|
||||
/// <summary>Compressed size in bytes.</summary>
|
||||
public ulong Compressed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialises compression metadata as a compact binary prefix.
|
||||
/// Format: 'c' 'm' 'p' <algorithmByte> <uvarint originalSize> <uvarint compressedSize>
|
||||
/// </summary>
|
||||
public byte[] MarshalMetadata()
|
||||
{
|
||||
Span<byte> scratch = stackalloc byte[32];
|
||||
var pos = 0;
|
||||
scratch[pos++] = (byte)'c';
|
||||
scratch[pos++] = (byte)'m';
|
||||
scratch[pos++] = (byte)'p';
|
||||
scratch[pos++] = (byte)Type;
|
||||
pos += WriteUVarInt(scratch[pos..], Original);
|
||||
pos += WriteUVarInt(scratch[pos..], Compressed);
|
||||
return scratch[..pos].ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises compression metadata from a binary buffer.
|
||||
/// Returns the number of bytes consumed, or 0 if the buffer does not start with the expected prefix.
|
||||
/// </summary>
|
||||
public int UnmarshalMetadata(byte[] b)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(b);
|
||||
|
||||
if (b.Length < 4 || b[0] != (byte)'c' || b[1] != (byte)'m' || b[2] != (byte)'p')
|
||||
return 0;
|
||||
|
||||
Type = (StoreCompression)b[3];
|
||||
var pos = 4;
|
||||
|
||||
if (!TryReadUVarInt(b.AsSpan(pos), out var original, out var used1))
|
||||
return 0;
|
||||
pos += used1;
|
||||
|
||||
if (!TryReadUVarInt(b.AsSpan(pos), out var compressed, out var used2))
|
||||
return 0;
|
||||
pos += used2;
|
||||
|
||||
Original = original;
|
||||
Compressed = compressed;
|
||||
return pos;
|
||||
}
|
||||
|
||||
private static int WriteUVarInt(Span<byte> dest, ulong value)
|
||||
{
|
||||
var i = 0;
|
||||
while (value >= 0x80)
|
||||
{
|
||||
dest[i++] = (byte)(value | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
dest[i++] = (byte)value;
|
||||
return i;
|
||||
}
|
||||
|
||||
private static bool TryReadUVarInt(ReadOnlySpan<byte> src, out ulong value, out int used)
|
||||
{
|
||||
value = 0;
|
||||
used = 0;
|
||||
var shift = 0;
|
||||
foreach (var b in src)
|
||||
{
|
||||
value |= (ulong)(b & 0x7F) << shift;
|
||||
used++;
|
||||
if ((b & 0x80) == 0)
|
||||
return true;
|
||||
shift += 7;
|
||||
if (shift > 63)
|
||||
return false;
|
||||
}
|
||||
|
||||
value = 0;
|
||||
used = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ErrBadMsg — corrupt/malformed message error (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Indicates a malformed or corrupt message was detected in a block file.
|
||||
/// Mirrors the <c>errBadMsg</c> type in filestore.go.
|
||||
/// </summary>
|
||||
internal sealed class ErrBadMsg : Exception
|
||||
{
|
||||
/// <summary>Path to the block file that contained the bad message.</summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>Optional additional detail about the corruption.</summary>
|
||||
public string Detail { get; }
|
||||
|
||||
public ErrBadMsg(string fileName, string detail = "")
|
||||
: base(BuildMessage(fileName, detail))
|
||||
{
|
||||
FileName = fileName;
|
||||
Detail = detail;
|
||||
}
|
||||
|
||||
private static string BuildMessage(string fileName, string detail)
|
||||
{
|
||||
var baseName = Path.GetFileName(fileName);
|
||||
return string.IsNullOrEmpty(detail)
|
||||
? $"malformed or corrupt message in {baseName}"
|
||||
: $"malformed or corrupt message in {baseName}: {detail}";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileStoreDefaults — well-known constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Well-known constants from filestore.go, exposed for cross-assembly use.
|
||||
/// </summary>
|
||||
public static class FileStoreDefaults
|
||||
{
|
||||
// Magic / version markers written into block files.
|
||||
|
||||
/// <summary>Magic byte used to identify file-store block files.</summary>
|
||||
public const byte FileStoreMagic = 22;
|
||||
|
||||
/// <summary>Current block file version.</summary>
|
||||
public const byte FileStoreVersion = 1;
|
||||
|
||||
/// <summary>New-format index version.</summary>
|
||||
internal const byte NewVersion = 2;
|
||||
|
||||
/// <summary>Header length in bytes for block records.</summary>
|
||||
internal const int HdrLen = 2;
|
||||
|
||||
// Directory names
|
||||
|
||||
/// <summary>Top-level directory that holds per-stream subdirectories.</summary>
|
||||
public const string StreamsDir = "streams";
|
||||
|
||||
/// <summary>Directory that holds in-flight batch data for a stream.</summary>
|
||||
public const string BatchesDir = "batches";
|
||||
|
||||
/// <summary>Directory that holds message block files.</summary>
|
||||
public const string MsgDir = "msgs";
|
||||
|
||||
/// <summary>Temporary directory name used during a full purge.</summary>
|
||||
public const string PurgeDir = "__msgs__";
|
||||
|
||||
/// <summary>Temporary directory name for the new message block during purge.</summary>
|
||||
public const string NewMsgDir = "__new_msgs__";
|
||||
|
||||
/// <summary>Directory name that holds per-consumer state.</summary>
|
||||
public const string ConsumerDir = "obs";
|
||||
|
||||
// File name patterns
|
||||
|
||||
/// <summary>Format string for block file names (<c>{index}.blk</c>).</summary>
|
||||
public const string BlkScan = "{0}.blk";
|
||||
|
||||
/// <summary>Suffix for active block files.</summary>
|
||||
public const string BlkSuffix = ".blk";
|
||||
|
||||
/// <summary>Format string for compacted-block staging files (<c>{index}.new</c>).</summary>
|
||||
public const string NewScan = "{0}.new";
|
||||
|
||||
/// <summary>Format string for index files (<c>{index}.idx</c>).</summary>
|
||||
public const string IndexScan = "{0}.idx";
|
||||
|
||||
/// <summary>Format string for per-block encryption-key files (<c>{index}.key</c>).</summary>
|
||||
public const string KeyScan = "{0}.key";
|
||||
|
||||
/// <summary>Glob pattern used to find orphaned key files.</summary>
|
||||
public const string KeyScanAll = "*.key";
|
||||
|
||||
/// <summary>Suffix for temporary rewrite/compression staging files.</summary>
|
||||
public const string BlkTmpSuffix = ".tmp";
|
||||
|
||||
// Meta files
|
||||
|
||||
/// <summary>Stream / consumer metadata file name.</summary>
|
||||
public const string JetStreamMetaFile = "meta.inf";
|
||||
|
||||
/// <summary>Checksum file for the metadata file.</summary>
|
||||
public const string JetStreamMetaFileSum = "meta.sum";
|
||||
|
||||
/// <summary>Encrypted metadata key file name.</summary>
|
||||
public const string JetStreamMetaFileKey = "meta.key";
|
||||
|
||||
/// <summary>Full stream-state snapshot file name.</summary>
|
||||
public const string StreamStateFile = "index.db";
|
||||
|
||||
/// <summary>Encoded TTL hash-wheel persistence file name.</summary>
|
||||
public const string TtlStreamStateFile = "thw.db";
|
||||
|
||||
/// <summary>Encoded message-scheduling persistence file name.</summary>
|
||||
public const string MsgSchedulingStreamStateFile = "sched.db";
|
||||
|
||||
/// <summary>Consumer state file name inside a consumer directory.</summary>
|
||||
public const string ConsumerState = "o.dat";
|
||||
|
||||
// Block size defaults (bytes)
|
||||
|
||||
/// <summary>Default block size for large (limits-based) streams: 8 MB.</summary>
|
||||
public const ulong DefaultLargeBlockSize = 8 * 1024 * 1024;
|
||||
|
||||
/// <summary>Default block size for work-queue / interest streams: 4 MB.</summary>
|
||||
public const ulong DefaultMediumBlockSize = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Default block size used by mirrors/sources: 1 MB.</summary>
|
||||
public const ulong DefaultSmallBlockSize = 1 * 1024 * 1024;
|
||||
|
||||
/// <summary>Tiny pool block size (256 KB) — avoids large allocations at low write rates.</summary>
|
||||
public const ulong DefaultTinyBlockSize = 256 * 1024;
|
||||
|
||||
/// <summary>Maximum encrypted-head block size: 2 MB.</summary>
|
||||
public const ulong MaximumEncryptedBlockSize = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Default block size for KV-based streams (same as medium).</summary>
|
||||
public const ulong DefaultKvBlockSize = DefaultMediumBlockSize;
|
||||
|
||||
/// <summary>Hard upper limit on block size.</summary>
|
||||
public const ulong MaxBlockSize = DefaultLargeBlockSize;
|
||||
|
||||
/// <summary>Minimum allowed block size: 32 KiB.</summary>
|
||||
public const ulong FileStoreMinBlkSize = 32 * 1000;
|
||||
|
||||
/// <summary>Maximum allowed block size (same as <see cref="MaxBlockSize"/>).</summary>
|
||||
public const ulong FileStoreMaxBlkSize = MaxBlockSize;
|
||||
|
||||
/// <summary>
|
||||
/// Default block size exposed publicly; resolves to <see cref="DefaultSmallBlockSize"/> (1 MB)
|
||||
/// to match the spec note in the porting plan.
|
||||
/// </summary>
|
||||
public const ulong DefaultBlockSize = DefaultSmallBlockSize;
|
||||
|
||||
// Timing defaults
|
||||
|
||||
/// <summary>Default duration before an idle cache buffer is expired: 10 seconds.</summary>
|
||||
public static readonly TimeSpan DefaultCacheBufferExpiration = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Default interval for background disk sync: 2 minutes.</summary>
|
||||
public static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>Default idle timeout before file descriptors are closed: 30 seconds.</summary>
|
||||
public static readonly TimeSpan CloseFdsIdle = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Default expiration time for idle per-block subject state: 2 minutes.</summary>
|
||||
public static readonly TimeSpan DefaultFssExpiration = TimeSpan.FromMinutes(2);
|
||||
|
||||
// Thresholds
|
||||
|
||||
/// <summary>Minimum coalesce size for write batching: 16 KiB.</summary>
|
||||
public const int CoalesceMinimum = 16 * 1024;
|
||||
|
||||
/// <summary>Maximum wait time when gathering messages to flush: 8 ms.</summary>
|
||||
public static readonly TimeSpan MaxFlushWait = TimeSpan.FromMilliseconds(8);
|
||||
|
||||
/// <summary>Minimum block size before compaction is attempted: 2 MB.</summary>
|
||||
public const int CompactMinimum = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Threshold above which a record length is considered corrupt: 32 MB.</summary>
|
||||
public const int RlBadThresh = 32 * 1024 * 1024;
|
||||
|
||||
/// <summary>Size of the per-record hash checksum in bytes.</summary>
|
||||
public const int RecordHashSize = 8;
|
||||
|
||||
// Encryption key size minimums
|
||||
|
||||
/// <summary>Minimum size of a metadata encryption key: 64 bytes.</summary>
|
||||
internal const int MinMetaKeySize = 64;
|
||||
|
||||
/// <summary>Minimum size of a block encryption key: 64 bytes.</summary>
|
||||
internal const int MinBlkKeySize = 64;
|
||||
|
||||
// Cache-index bit flags
|
||||
|
||||
/// <summary>Bit set in a cache index slot to mark that the checksum has been validated.</summary>
|
||||
internal const uint Cbit = 1u << 31;
|
||||
|
||||
/// <summary>Bit set in a cache index slot to mark the message as deleted.</summary>
|
||||
internal const uint Dbit = 1u << 30;
|
||||
|
||||
/// <summary>Bit set in a record length field to indicate the record has headers.</summary>
|
||||
internal const uint Hbit = 1u << 31;
|
||||
|
||||
/// <summary>Bit set in a sequence number to mark an erased message.</summary>
|
||||
internal const ulong Ebit = 1UL << 63;
|
||||
|
||||
/// <summary>Bit set in a sequence number to mark a tombstone.</summary>
|
||||
internal const ulong Tbit = 1UL << 62;
|
||||
}
|
||||
614
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApiTypes.cs
Normal file
614
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApiTypes.cs
Normal file
@@ -0,0 +1,614 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jetstream_api.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forward stubs for types defined in later sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Stub: stored message type — full definition in session 20.</summary>
|
||||
public sealed class StoredMsg { }
|
||||
|
||||
/// <summary>Priority group for pull consumers — full definition in session 20.</summary>
|
||||
public sealed class PriorityGroup { }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API subject constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// JetStream API subject constants.
|
||||
/// Mirrors the const block at the top of server/jetstream_api.go.
|
||||
/// </summary>
|
||||
public static class JsApiSubjects
|
||||
{
|
||||
public const string JsAllApi = "$JS.API.>";
|
||||
public const string JsApiPrefix = "$JS.API";
|
||||
public const string JsApiAccountInfo = "$JS.API.INFO";
|
||||
|
||||
public const string JsApiStreamCreate = "$JS.API.STREAM.CREATE.*";
|
||||
public const string JsApiStreamCreateT = "$JS.API.STREAM.CREATE.{0}";
|
||||
public const string JsApiStreamUpdate = "$JS.API.STREAM.UPDATE.*";
|
||||
public const string JsApiStreamUpdateT = "$JS.API.STREAM.UPDATE.{0}";
|
||||
public const string JsApiStreams = "$JS.API.STREAM.NAMES";
|
||||
public const string JsApiStreamList = "$JS.API.STREAM.LIST";
|
||||
public const string JsApiStreamInfo = "$JS.API.STREAM.INFO.*";
|
||||
public const string JsApiStreamInfoT = "$JS.API.STREAM.INFO.{0}";
|
||||
public const string JsApiStreamDelete = "$JS.API.STREAM.DELETE.*";
|
||||
public const string JsApiStreamDeleteT = "$JS.API.STREAM.DELETE.{0}";
|
||||
public const string JsApiStreamPurge = "$JS.API.STREAM.PURGE.*";
|
||||
public const string JsApiStreamPurgeT = "$JS.API.STREAM.PURGE.{0}";
|
||||
public const string JsApiStreamSnapshot = "$JS.API.STREAM.SNAPSHOT.*";
|
||||
public const string JsApiStreamSnapshotT = "$JS.API.STREAM.SNAPSHOT.{0}";
|
||||
public const string JsApiStreamRestore = "$JS.API.STREAM.RESTORE.*";
|
||||
public const string JsApiStreamRestoreT = "$JS.API.STREAM.RESTORE.{0}";
|
||||
public const string JsApiMsgDelete = "$JS.API.STREAM.MSG.DELETE.*";
|
||||
public const string JsApiMsgDeleteT = "$JS.API.STREAM.MSG.DELETE.{0}";
|
||||
public const string JsApiMsgGet = "$JS.API.STREAM.MSG.GET.*";
|
||||
public const string JsApiMsgGetT = "$JS.API.STREAM.MSG.GET.{0}";
|
||||
public const string JsDirectMsgGet = "$JS.API.DIRECT.GET.*";
|
||||
public const string JsDirectMsgGetT = "$JS.API.DIRECT.GET.{0}";
|
||||
public const string JsDirectGetLastBySubject = "$JS.API.DIRECT.GET.*.>";
|
||||
public const string JsDirectGetLastBySubjectT = "$JS.API.DIRECT.GET.{0}.{1}";
|
||||
|
||||
public const string JsApiConsumerCreate = "$JS.API.CONSUMER.CREATE.*";
|
||||
public const string JsApiConsumerCreateT = "$JS.API.CONSUMER.CREATE.{0}";
|
||||
public const string JsApiConsumerCreateEx = "$JS.API.CONSUMER.CREATE.*.>";
|
||||
public const string JsApiConsumerCreateExT = "$JS.API.CONSUMER.CREATE.{0}.{1}.{2}";
|
||||
public const string JsApiDurableCreate = "$JS.API.CONSUMER.DURABLE.CREATE.*.*";
|
||||
public const string JsApiDurableCreateT = "$JS.API.CONSUMER.DURABLE.CREATE.{0}.{1}";
|
||||
public const string JsApiConsumers = "$JS.API.CONSUMER.NAMES.*";
|
||||
public const string JsApiConsumersT = "$JS.API.CONSUMER.NAMES.{0}";
|
||||
public const string JsApiConsumerList = "$JS.API.CONSUMER.LIST.*";
|
||||
public const string JsApiConsumerListT = "$JS.API.CONSUMER.LIST.{0}";
|
||||
public const string JsApiConsumerInfo = "$JS.API.CONSUMER.INFO.*.*";
|
||||
public const string JsApiConsumerInfoT = "$JS.API.CONSUMER.INFO.{0}.{1}";
|
||||
public const string JsApiConsumerDelete = "$JS.API.CONSUMER.DELETE.*.*";
|
||||
public const string JsApiConsumerDeleteT = "$JS.API.CONSUMER.DELETE.{0}.{1}";
|
||||
public const string JsApiConsumerPause = "$JS.API.CONSUMER.PAUSE.*.*";
|
||||
public const string JsApiConsumerPauseT = "$JS.API.CONSUMER.PAUSE.{0}.{1}";
|
||||
public const string JsApiRequestNextT = "$JS.API.CONSUMER.MSG.NEXT.{0}.{1}";
|
||||
public const string JsApiConsumerResetT = "$JS.API.CONSUMER.RESET.{0}.{1}";
|
||||
public const string JsApiConsumerUnpin = "$JS.API.CONSUMER.UNPIN.*.*";
|
||||
public const string JsApiConsumerUnpinT = "$JS.API.CONSUMER.UNPIN.{0}.{1}";
|
||||
|
||||
public const string JsApiStreamRemovePeer = "$JS.API.STREAM.PEER.REMOVE.*";
|
||||
public const string JsApiStreamRemovePeerT = "$JS.API.STREAM.PEER.REMOVE.{0}";
|
||||
public const string JsApiStreamLeaderStepDown = "$JS.API.STREAM.LEADER.STEPDOWN.*";
|
||||
public const string JsApiStreamLeaderStepDownT = "$JS.API.STREAM.LEADER.STEPDOWN.{0}";
|
||||
public const string JsApiConsumerLeaderStepDown = "$JS.API.CONSUMER.LEADER.STEPDOWN.*.*";
|
||||
public const string JsApiConsumerLeaderStepDownT = "$JS.API.CONSUMER.LEADER.STEPDOWN.{0}.{1}";
|
||||
public const string JsApiLeaderStepDown = "$JS.API.META.LEADER.STEPDOWN";
|
||||
public const string JsApiRemoveServer = "$JS.API.SERVER.REMOVE";
|
||||
public const string JsApiAccountPurge = "$JS.API.ACCOUNT.PURGE.*";
|
||||
public const string JsApiAccountPurgeT = "$JS.API.ACCOUNT.PURGE.{0}";
|
||||
public const string JsApiServerStreamMove = "$JS.API.ACCOUNT.STREAM.MOVE.*.*";
|
||||
public const string JsApiServerStreamMoveT = "$JS.API.ACCOUNT.STREAM.MOVE.{0}.{1}";
|
||||
public const string JsApiServerStreamCancelMove = "$JS.API.ACCOUNT.STREAM.CANCEL_MOVE.*.*";
|
||||
public const string JsApiServerStreamCancelMoveT = "$JS.API.ACCOUNT.STREAM.CANCEL_MOVE.{0}.{1}";
|
||||
|
||||
// Advisory/metric subjects
|
||||
public const string JsAdvisoryPrefix = "$JS.EVENT.ADVISORY";
|
||||
public const string JsMetricPrefix = "$JS.EVENT.METRIC";
|
||||
public const string JsMetricConsumerAckPre = "$JS.EVENT.METRIC.CONSUMER.ACK";
|
||||
public const string JsAdvisoryConsumerMaxDelivery = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES";
|
||||
public const string JsAdvisoryConsumerMsgNak = "$JS.EVENT.ADVISORY.CONSUMER.MSG_NAKED";
|
||||
public const string JsAdvisoryConsumerMsgTerminated = "$JS.EVENT.ADVISORY.CONSUMER.MSG_TERMINATED";
|
||||
public const string JsAdvisoryStreamCreated = "$JS.EVENT.ADVISORY.STREAM.CREATED";
|
||||
public const string JsAdvisoryStreamDeleted = "$JS.EVENT.ADVISORY.STREAM.DELETED";
|
||||
public const string JsAdvisoryStreamUpdated = "$JS.EVENT.ADVISORY.STREAM.UPDATED";
|
||||
public const string JsAdvisoryConsumerCreated = "$JS.EVENT.ADVISORY.CONSUMER.CREATED";
|
||||
public const string JsAdvisoryConsumerDeleted = "$JS.EVENT.ADVISORY.CONSUMER.DELETED";
|
||||
public const string JsAdvisoryConsumerPause = "$JS.EVENT.ADVISORY.CONSUMER.PAUSE";
|
||||
public const string JsAdvisoryConsumerPinned = "$JS.EVENT.ADVISORY.CONSUMER.PINNED";
|
||||
public const string JsAdvisoryConsumerUnpinned = "$JS.EVENT.ADVISORY.CONSUMER.UNPINNED";
|
||||
public const string JsAdvisoryStreamSnapshotCreate = "$JS.EVENT.ADVISORY.STREAM.SNAPSHOT_CREATE";
|
||||
public const string JsAdvisoryStreamSnapshotComplete = "$JS.EVENT.ADVISORY.STREAM.SNAPSHOT_COMPLETE";
|
||||
public const string JsAdvisoryStreamRestoreCreate = "$JS.EVENT.ADVISORY.STREAM.RESTORE_CREATE";
|
||||
public const string JsAdvisoryStreamRestoreComplete = "$JS.EVENT.ADVISORY.STREAM.RESTORE_COMPLETE";
|
||||
public const string JsAdvisoryDomainLeaderElected = "$JS.EVENT.ADVISORY.DOMAIN.LEADER_ELECTED";
|
||||
public const string JsAdvisoryStreamLeaderElected = "$JS.EVENT.ADVISORY.STREAM.LEADER_ELECTED";
|
||||
public const string JsAdvisoryStreamQuorumLost = "$JS.EVENT.ADVISORY.STREAM.QUORUM_LOST";
|
||||
public const string JsAdvisoryStreamBatchAbandoned = "$JS.EVENT.ADVISORY.STREAM.BATCH_ABANDONED";
|
||||
public const string JsAdvisoryConsumerLeaderElected = "$JS.EVENT.ADVISORY.CONSUMER.LEADER_ELECTED";
|
||||
public const string JsAdvisoryConsumerQuorumLost = "$JS.EVENT.ADVISORY.CONSUMER.QUORUM_LOST";
|
||||
public const string JsAdvisoryServerOutOfStorage = "$JS.EVENT.ADVISORY.SERVER.OUT_OF_STORAGE";
|
||||
public const string JsAdvisoryServerRemoved = "$JS.EVENT.ADVISORY.SERVER.REMOVED";
|
||||
public const string JsAdvisoryApiLimitReached = "$JS.EVENT.ADVISORY.API.LIMIT_REACHED";
|
||||
public const string JsAuditAdvisory = "$JS.EVENT.ADVISORY.API";
|
||||
|
||||
// Response type strings
|
||||
public const string JsApiSystemResponseType = "io.nats.jetstream.api.v1.system_response";
|
||||
public const string JsApiOverloadedType = "io.nats.jetstream.api.v1.system_overloaded";
|
||||
public const string JsApiAccountInfoResponseType = "io.nats.jetstream.api.v1.account_info_response";
|
||||
public const string JsApiStreamCreateResponseType = "io.nats.jetstream.api.v1.stream_create_response";
|
||||
public const string JsApiStreamDeleteResponseType = "io.nats.jetstream.api.v1.stream_delete_response";
|
||||
public const string JsApiStreamInfoResponseType = "io.nats.jetstream.api.v1.stream_info_response";
|
||||
public const string JsApiStreamNamesResponseType = "io.nats.jetstream.api.v1.stream_names_response";
|
||||
public const string JsApiStreamListResponseType = "io.nats.jetstream.api.v1.stream_list_response";
|
||||
public const string JsApiStreamPurgeResponseType = "io.nats.jetstream.api.v1.stream_purge_response";
|
||||
public const string JsApiStreamUpdateResponseType = "io.nats.jetstream.api.v1.stream_update_response";
|
||||
public const string JsApiMsgDeleteResponseType = "io.nats.jetstream.api.v1.stream_msg_delete_response";
|
||||
public const string JsApiStreamSnapshotResponseType = "io.nats.jetstream.api.v1.stream_snapshot_response";
|
||||
public const string JsApiStreamRestoreResponseType = "io.nats.jetstream.api.v1.stream_restore_response";
|
||||
public const string JsApiStreamRemovePeerResponseType = "io.nats.jetstream.api.v1.stream_remove_peer_response";
|
||||
public const string JsApiStreamLeaderStepDownResponseType = "io.nats.jetstream.api.v1.stream_leader_stepdown_response";
|
||||
public const string JsApiConsumerLeaderStepDownResponseType = "io.nats.jetstream.api.v1.consumer_leader_stepdown_response";
|
||||
public const string JsApiLeaderStepDownResponseType = "io.nats.jetstream.api.v1.meta_leader_stepdown_response";
|
||||
public const string JsApiMetaServerRemoveResponseType = "io.nats.jetstream.api.v1.meta_server_remove_response";
|
||||
public const string JsApiAccountPurgeResponseType = "io.nats.jetstream.api.v1.account_purge_response";
|
||||
public const string JsApiMsgGetResponseType = "io.nats.jetstream.api.v1.stream_msg_get_response";
|
||||
public const string JsApiConsumerCreateResponseType = "io.nats.jetstream.api.v1.consumer_create_response";
|
||||
public const string JsApiConsumerDeleteResponseType = "io.nats.jetstream.api.v1.consumer_delete_response";
|
||||
public const string JsApiConsumerPauseResponseType = "io.nats.jetstream.api.v1.consumer_pause_response";
|
||||
public const string JsApiConsumerInfoResponseType = "io.nats.jetstream.api.v1.consumer_info_response";
|
||||
public const string JsApiConsumerNamesResponseType = "io.nats.jetstream.api.v1.consumer_names_response";
|
||||
public const string JsApiConsumerListResponseType = "io.nats.jetstream.api.v1.consumer_list_response";
|
||||
public const string JsApiConsumerUnpinResponseType = "io.nats.jetstream.api.v1.consumer_unpin_response";
|
||||
public const string JsApiConsumerResetResponseType = "io.nats.jetstream.api.v1.consumer_reset_response";
|
||||
|
||||
// Limits
|
||||
public const int JsApiNamesLimit = 1024;
|
||||
public const int JsApiListLimit = 256;
|
||||
public const int JsMaxSubjectDetails = 100_000;
|
||||
public const int JsWaitQueueDefaultMax = 512;
|
||||
public const int JsMaxDescriptionLen = 4 * 1024;
|
||||
public const int JsMaxMetadataLen = 128 * 1024;
|
||||
public const int JsMaxNameLen = 255;
|
||||
public const int JsDefaultRequestQueueLimit = 10_000;
|
||||
|
||||
// Request headers
|
||||
public const string JsRequiredApiLevel = "Nats-Required-Api-Level";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Standard base response from the JetStream JSON API.
|
||||
/// Mirrors <c>ApiResponse</c> in server/jetstream_api.go.
|
||||
/// </summary>
|
||||
public sealed class ApiResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
|
||||
/// <summary>Returns the <see cref="Error"/> as an exception, or null if none.</summary>
|
||||
public Exception? ToError() =>
|
||||
Error is null ? null : new InvalidOperationException($"{Error.Description} ({Error.ErrCode})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paged response metadata included in list responses.
|
||||
/// Mirrors <c>ApiPaged</c> in server/jetstream_api.go.
|
||||
/// </summary>
|
||||
public sealed class ApiPaged
|
||||
{
|
||||
[JsonPropertyName("total")] public int Total { get; set; }
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for paged API responses.
|
||||
/// Mirrors <c>ApiPagedRequest</c> in server/jetstream_api.go.
|
||||
/// </summary>
|
||||
public sealed class ApiPagedRequest
|
||||
{
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Account info response.</summary>
|
||||
public sealed class JsApiAccountInfoResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
|
||||
// JetStreamAccountStats fields (embedded in Go)
|
||||
[JsonPropertyName("memory")] public ulong Memory { get; set; }
|
||||
[JsonPropertyName("storage")] public ulong Store { get; set; }
|
||||
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
|
||||
[JsonPropertyName("reserved_storage")]public ulong ReservedStore { get; set; }
|
||||
[JsonPropertyName("streams")] public int Streams { get; set; }
|
||||
[JsonPropertyName("consumers")] public int Consumers { get; set; }
|
||||
[JsonPropertyName("limits")] public JetStreamAccountLimits Limits { get; set; } = new();
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
[JsonPropertyName("api")] public JetStreamApiStats Api { get; set; } = new();
|
||||
[JsonPropertyName("tiers")] public Dictionary<string, JetStreamTier>? Tiers { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Stream creation response.</summary>
|
||||
public sealed class JsApiStreamCreateResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("config")] public StreamConfig? Config { get; set; }
|
||||
[JsonPropertyName("state")] public StreamState? State { get; set; }
|
||||
[JsonPropertyName("did_create")] public bool DidCreate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream deletion response.</summary>
|
||||
public sealed class JsApiStreamDeleteResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream info request with optional filtering.</summary>
|
||||
public sealed class JsApiStreamInfoRequest
|
||||
{
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("deleted_details")] public bool DeletedDetails { get; set; }
|
||||
[JsonPropertyName("subjects_filter")] public string? SubjectsFilter { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream info response.</summary>
|
||||
public sealed class JsApiStreamInfoResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("total")] public int Total { get; set; }
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||
// StreamInfo fields embedded — delegated to StreamInfo stub for now
|
||||
public StreamInfo? Info { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream names list request.</summary>
|
||||
public sealed class JsApiStreamNamesRequest
|
||||
{
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream names list response.</summary>
|
||||
public sealed class JsApiStreamNamesResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("total")] public int Total { get; set; }
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||
[JsonPropertyName("streams")] public List<string>? Streams { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Detailed stream list request.</summary>
|
||||
public sealed class JsApiStreamListRequest
|
||||
{
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Detailed stream list response.</summary>
|
||||
public sealed class JsApiStreamListResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("total")] public int Total { get; set; }
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||
[JsonPropertyName("streams")] public List<StreamInfo>? Streams { get; set; }
|
||||
[JsonPropertyName("missing")] public List<string>? Missing { get; set; }
|
||||
[JsonPropertyName("offline")] public Dictionary<string, string>? Offline { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream purge request.</summary>
|
||||
public sealed class JsApiStreamPurgeRequest
|
||||
{
|
||||
[JsonPropertyName("seq")] public ulong Sequence { get; set; }
|
||||
[JsonPropertyName("filter")] public string? Subject { get; set; }
|
||||
[JsonPropertyName("keep")] public ulong Keep { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream purge response.</summary>
|
||||
public sealed class JsApiStreamPurgeResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
[JsonPropertyName("purged")] public ulong Purged { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream update response.</summary>
|
||||
public sealed class JsApiStreamUpdateResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
public StreamInfo? Info { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Message deletion request.</summary>
|
||||
public sealed class JsApiMsgDeleteRequest
|
||||
{
|
||||
[JsonPropertyName("seq")] public ulong Seq { get; set; }
|
||||
[JsonPropertyName("no_erase")] public bool NoErase { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Message deletion response.</summary>
|
||||
public sealed class JsApiMsgDeleteResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream snapshot request.</summary>
|
||||
public sealed class JsApiStreamSnapshotRequest
|
||||
{
|
||||
[JsonPropertyName("deliver_subject")] public string DeliverSubject { get; set; } = string.Empty;
|
||||
[JsonPropertyName("no_consumers")] public bool NoConsumers { get; set; }
|
||||
[JsonPropertyName("chunk_size")] public int ChunkSize { get; set; }
|
||||
[JsonPropertyName("window_size")] public int WindowSize { get; set; }
|
||||
[JsonPropertyName("jsck")] public bool CheckMsgs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Direct stream snapshot response.</summary>
|
||||
public sealed class JsApiStreamSnapshotResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("config")] public StreamConfig? Config { get; set; }
|
||||
[JsonPropertyName("state")] public StreamState? State { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stream restore request.</summary>
|
||||
public sealed class JsApiStreamRestoreRequest
|
||||
{
|
||||
[JsonPropertyName("config")] public StreamConfig Config { get; set; } = new();
|
||||
[JsonPropertyName("state")] public StreamState State { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Stream restore response.</summary>
|
||||
public sealed class JsApiStreamRestoreResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("deliver_subject")] public string DeliverSubject { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Remove a peer from a stream.</summary>
|
||||
public sealed class JsApiStreamRemovePeerRequest
|
||||
{
|
||||
[JsonPropertyName("peer")] public string Peer { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response to remove peer request.</summary>
|
||||
public sealed class JsApiStreamRemovePeerResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response to stream leader step-down.</summary>
|
||||
public sealed class JsApiStreamLeaderStepDownResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response to consumer leader step-down.</summary>
|
||||
public sealed class JsApiConsumerLeaderStepDownResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Meta-leader step-down request with optional placement.</summary>
|
||||
public sealed class JsApiLeaderStepdownRequest
|
||||
{
|
||||
[JsonPropertyName("placement")] public Placement? Placement { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response to meta-leader step-down.</summary>
|
||||
public sealed class JsApiLeaderStepDownResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Remove a peer server from the meta group.</summary>
|
||||
public sealed class JsApiMetaServerRemoveRequest
|
||||
{
|
||||
[JsonPropertyName("peer")] public string Server { get; set; } = string.Empty;
|
||||
[JsonPropertyName("peer_id")] public string? PeerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Response to peer removal.</summary>
|
||||
public sealed class JsApiMetaServerRemoveResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Request to move a stream off a server.</summary>
|
||||
public sealed class JsApiMetaServerStreamMoveRequest
|
||||
{
|
||||
[JsonPropertyName("server")] public string? Server { get; set; }
|
||||
[JsonPropertyName("cluster")] public string? Cluster { get; set; }
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
[JsonPropertyName("tags")] public string[]? Tags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Account purge response.</summary>
|
||||
public sealed class JsApiAccountPurgeResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("initiated")] public bool Initiated { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message get
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Direct message get request (by sequence, last-for-subject, next-for-subject, or batch).
|
||||
/// Mirrors <c>JSApiMsgGetRequest</c> in server/jetstream_api.go.
|
||||
/// </summary>
|
||||
public sealed class JsApiMsgGetRequest
|
||||
{
|
||||
[JsonPropertyName("seq")] public ulong Seq { get; set; }
|
||||
[JsonPropertyName("last_by_subj")] public string? LastFor { get; set; }
|
||||
[JsonPropertyName("next_by_subj")] public string? NextFor { get; set; }
|
||||
[JsonPropertyName("batch")] public int Batch { get; set; }
|
||||
[JsonPropertyName("max_bytes")] public int MaxBytes { get; set; }
|
||||
[JsonPropertyName("start_time")] public DateTime? StartTime { get; set; }
|
||||
[JsonPropertyName("multi_last")] public string[]? MultiLastFor { get; set; }
|
||||
[JsonPropertyName("up_to_seq")] public ulong UpToSeq { get; set; }
|
||||
[JsonPropertyName("up_to_time")] public DateTime? UpToTime { get; set; }
|
||||
[JsonPropertyName("no_hdr")] public bool NoHeaders { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Message get response.</summary>
|
||||
public sealed class JsApiMsgGetResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("message")] public StoredMsg? Message { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consumer API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Consumer create/update response.</summary>
|
||||
public sealed class JsApiConsumerCreateResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
public ConsumerInfo? Info { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer delete response.</summary>
|
||||
public sealed class JsApiConsumerDeleteResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer pause request.</summary>
|
||||
public sealed class JsApiConsumerPauseRequest
|
||||
{
|
||||
[JsonPropertyName("pause_until")] public DateTime PauseUntil { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer pause response.</summary>
|
||||
public sealed class JsApiConsumerPauseResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("paused")] public bool Paused { get; set; }
|
||||
[JsonPropertyName("pause_until")] public DateTime PauseUntil { get; set; }
|
||||
[JsonPropertyName("pause_remaining")] public TimeSpan PauseRemaining { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer info response.</summary>
|
||||
public sealed class JsApiConsumerInfoResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
public ConsumerInfo? Info { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer names request (paged).</summary>
|
||||
public sealed class JsApiConsumersRequest
|
||||
{
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer names list response.</summary>
|
||||
public sealed class JsApiConsumerNamesResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("total")] public int Total { get; set; }
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||
[JsonPropertyName("consumers")] public List<string>? Consumers { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer list response.</summary>
|
||||
public sealed class JsApiConsumerListResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("total")] public int Total { get; set; }
|
||||
[JsonPropertyName("offset")] public int Offset { get; set; }
|
||||
[JsonPropertyName("limit")] public int Limit { get; set; }
|
||||
[JsonPropertyName("consumers")] public List<ConsumerInfo>? Consumers { get; set; }
|
||||
[JsonPropertyName("missing")] public List<string>? Missing { get; set; }
|
||||
[JsonPropertyName("offline")] public Dictionary<string, string>? Offline { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull consumer next-message request.
|
||||
/// Mirrors <c>JSApiConsumerGetNextRequest</c> in server/jetstream_api.go.
|
||||
/// </summary>
|
||||
public sealed class JsApiConsumerGetNextRequest
|
||||
{
|
||||
[JsonPropertyName("expires")] public TimeSpan Expires { get; set; }
|
||||
[JsonPropertyName("batch")] public int Batch { get; set; }
|
||||
[JsonPropertyName("max_bytes")] public int MaxBytes { get; set; }
|
||||
[JsonPropertyName("no_wait")] public bool NoWait { get; set; }
|
||||
[JsonPropertyName("idle_heartbeat")] public TimeSpan Heartbeat { get; set; }
|
||||
public PriorityGroup? Priority { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer reset (seek to sequence) request.</summary>
|
||||
public sealed class JsApiConsumerResetRequest
|
||||
{
|
||||
[JsonPropertyName("seq")] public ulong Seq { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer reset response.</summary>
|
||||
public sealed class JsApiConsumerResetResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
[JsonPropertyName("reset_seq")] public ulong ResetSeq { get; set; }
|
||||
public ConsumerInfo? Info { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Consumer unpin (priority group) request.</summary>
|
||||
public sealed class JsApiConsumerUnpinRequest
|
||||
{
|
||||
[JsonPropertyName("group")] public string Group { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Consumer unpin response.</summary>
|
||||
public sealed class JsApiConsumerUnpinResponse
|
||||
{
|
||||
[JsonPropertyName("type")] public string? Type { get; set; }
|
||||
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
|
||||
}
|
||||
132
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamBatching.cs
Normal file
132
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamBatching.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright 2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jetstream_batching.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batching types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Tracks in-progress atomic publish batch groups for a stream.
|
||||
/// Mirrors the <c>batching</c> struct in server/jetstream_batching.go.
|
||||
/// </summary>
|
||||
internal sealed class Batching
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
private readonly Dictionary<string, BatchGroup> _group = new(StringComparer.Ordinal);
|
||||
|
||||
public Lock Mu => _mu;
|
||||
public Dictionary<string, BatchGroup> Group => _group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single in-progress atomic batch: its temporary store and cleanup timer.
|
||||
/// Mirrors <c>batchGroup</c> in server/jetstream_batching.go.
|
||||
/// </summary>
|
||||
internal sealed class BatchGroup
|
||||
{
|
||||
/// <summary>Last proposed stream sequence for this batch.</summary>
|
||||
public ulong LastSeq { get; set; }
|
||||
|
||||
/// <summary>Temporary backing store for the batch's messages.</summary>
|
||||
public object? Store { get; set; } // IStreamStore — session 20
|
||||
|
||||
/// <summary>Timer that abandons the batch after the configured timeout.</summary>
|
||||
public Timer? BatchTimer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stops the cleanup timer and flushes pending writes so the batch is
|
||||
/// ready to be committed.
|
||||
/// Mirrors <c>batchGroup.readyForCommit</c>.
|
||||
/// </summary>
|
||||
public bool ReadyForCommit()
|
||||
{
|
||||
// Stub — full implementation requires IStreamStore.FlushAllPending (session 20).
|
||||
return BatchTimer?.Change(Timeout.Infinite, Timeout.Infinite) != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stages consistency-check data for a single atomic batch before it is committed.
|
||||
/// Mirrors <c>batchStagedDiff</c> in server/jetstream_batching.go.
|
||||
/// </summary>
|
||||
internal sealed class BatchStagedDiff
|
||||
{
|
||||
/// <summary>Message IDs seen in this batch, for duplicate detection.</summary>
|
||||
public Dictionary<string, object?>? MsgIds { get; set; }
|
||||
|
||||
/// <summary>Running counter totals, keyed by subject.</summary>
|
||||
public Dictionary<string, object?>? Counter { get; set; } // map[string]*msgCounterRunningTotal
|
||||
|
||||
/// <summary>Inflight subject byte/op totals for DiscardNew checks.</summary>
|
||||
public Dictionary<string, object?>? Inflight { get; set; } // map[string]*inflightSubjectRunningTotal
|
||||
|
||||
/// <summary>Expected-last-seq-per-subject checks staged in this batch.</summary>
|
||||
public Dictionary<string, BatchExpectedPerSubject>? ExpectedPerSubject { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached expected-last-sequence-per-subject result for a single subject within a batch.
|
||||
/// Mirrors <c>batchExpectedPerSubject</c> in server/jetstream_batching.go.
|
||||
/// </summary>
|
||||
internal sealed class BatchExpectedPerSubject
|
||||
{
|
||||
/// <summary>Stream sequence of the last message on this subject at proposal time.</summary>
|
||||
public ulong SSeq { get; set; }
|
||||
|
||||
/// <summary>Clustered proposal sequence at which this check was computed.</summary>
|
||||
public ulong ClSeq { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the in-progress application of a committed batch on the Raft apply path.
|
||||
/// Mirrors <c>batchApply</c> in server/jetstream_batching.go.
|
||||
/// </summary>
|
||||
internal sealed class BatchApply
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
|
||||
/// <summary>ID of the current batch.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Number of entries expected in the batch (for consistency checks).</summary>
|
||||
public ulong Count { get; set; }
|
||||
|
||||
/// <summary>Raft committed entries that make up this batch.</summary>
|
||||
public List<object?>? Entries { get; set; } // []*CommittedEntry — session 20+
|
||||
|
||||
/// <summary>Index within an entry indicating the first message of the batch.</summary>
|
||||
public int EntryStart { get; set; }
|
||||
|
||||
/// <summary>Applied value before the entry containing the first batch message.</summary>
|
||||
public ulong MaxApplied { get; set; }
|
||||
|
||||
public Lock Mu => _mu;
|
||||
|
||||
/// <summary>
|
||||
/// Clears in-memory apply-batch state.
|
||||
/// Mirrors <c>batchApply.clearBatchStateLocked</c>.
|
||||
/// Lock should be held.
|
||||
/// </summary>
|
||||
public void ClearBatchStateLocked()
|
||||
{
|
||||
Id = string.Empty;
|
||||
Count = 0;
|
||||
Entries = null;
|
||||
EntryStart = 0;
|
||||
MaxApplied = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jetstream_cluster.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// JetStreamCluster
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds cluster-level JetStream state on each server: the meta-controller Raft node,
|
||||
/// stream/consumer assignment maps, and inflight proposal tracking.
|
||||
/// Mirrors Go <c>jetStreamCluster</c> struct in server/jetstream_cluster.go lines 44-84.
|
||||
/// </summary>
|
||||
internal sealed class JetStreamCluster
|
||||
{
|
||||
/// <summary>The meta-controller Raft node.</summary>
|
||||
public IRaftNode? Meta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All known stream assignments. Key: account name → stream name → assignment.
|
||||
/// </summary>
|
||||
public Dictionary<string, Dictionary<string, StreamAssignment>> Streams { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inflight stream create/update/delete proposals. Key: account → stream name.
|
||||
/// </summary>
|
||||
public Dictionary<string, Dictionary<string, InflightStreamInfo>> InflightStreams { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inflight consumer create/update/delete proposals. Key: account → stream → consumer.
|
||||
/// </summary>
|
||||
public Dictionary<string, Dictionary<string, Dictionary<string, InflightConsumerInfo>>> InflightConsumers { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Peer-remove reply subjects pending quorum. Key: peer ID.
|
||||
/// </summary>
|
||||
public Dictionary<string, PeerRemoveInfo> PeerRemoveReply { get; set; } = new();
|
||||
|
||||
/// <summary>Signals meta-leader should re-check stream assignments.</summary>
|
||||
public bool StreamsCheck { get; set; }
|
||||
|
||||
/// <summary>Reference to the top-level server (object to avoid circular dep).</summary>
|
||||
public object? Server { get; set; }
|
||||
|
||||
/// <summary>Internal client for cluster messaging (object to avoid circular dep).</summary>
|
||||
public object? Client { get; set; }
|
||||
|
||||
/// <summary>Subscription that receives stream assignment results (object to avoid session dep).</summary>
|
||||
public object? StreamResults { get; set; }
|
||||
|
||||
/// <summary>Subscription that receives consumer assignment results (object to avoid session dep).</summary>
|
||||
public object? ConsumerResults { get; set; }
|
||||
|
||||
/// <summary>Subscription for meta-leader step-down requests.</summary>
|
||||
public object? Stepdown { get; set; }
|
||||
|
||||
/// <summary>Subscription for peer-remove requests.</summary>
|
||||
public object? PeerRemove { get; set; }
|
||||
|
||||
/// <summary>Subscription for stream-move requests.</summary>
|
||||
public object? PeerStreamMove { get; set; }
|
||||
|
||||
/// <summary>Subscription for stream-move cancellation.</summary>
|
||||
public object? PeerStreamCancelMove { get; set; }
|
||||
|
||||
/// <summary>Channel used to pop out monitorCluster before the raft layer.</summary>
|
||||
public System.Threading.Channels.Channel<bool>? Qch { get; set; }
|
||||
|
||||
/// <summary>Notifies that monitorCluster has actually stopped.</summary>
|
||||
public System.Threading.Channels.Channel<bool>? Stopped { get; set; }
|
||||
|
||||
/// <summary>Last meta-snapshot time (Unix nanoseconds).</summary>
|
||||
public long LastMetaSnapTime { get; set; }
|
||||
|
||||
/// <summary>Duration of last meta-snapshot (nanoseconds).</summary>
|
||||
public long LastMetaSnapDuration { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InflightStreamInfo
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks inflight stream create/update/delete proposals.
|
||||
/// Mirrors Go <c>inflightStreamInfo</c> in server/jetstream_cluster.go lines 87-91.
|
||||
/// </summary>
|
||||
internal sealed class InflightStreamInfo
|
||||
{
|
||||
/// <summary>Number of inflight operations.</summary>
|
||||
public ulong Ops { get; set; }
|
||||
/// <summary>Whether the stream has been deleted.</summary>
|
||||
public bool Deleted { get; set; }
|
||||
public StreamAssignment? Assignment { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InflightConsumerInfo
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks inflight consumer create/update/delete proposals.
|
||||
/// Mirrors Go <c>inflightConsumerInfo</c> in server/jetstream_cluster.go lines 94-98.
|
||||
/// </summary>
|
||||
internal sealed class InflightConsumerInfo
|
||||
{
|
||||
/// <summary>Number of inflight operations.</summary>
|
||||
public ulong Ops { get; set; }
|
||||
/// <summary>Whether the consumer has been deleted.</summary>
|
||||
public bool Deleted { get; set; }
|
||||
public ConsumerAssignment? Assignment { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PeerRemoveInfo
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds the reply information for a peer-remove request pending quorum.
|
||||
/// Mirrors Go <c>peerRemoveInfo</c> in server/jetstream_cluster.go lines 101-106.
|
||||
/// </summary>
|
||||
internal sealed class PeerRemoveInfo
|
||||
{
|
||||
/// <summary>Client info from the request (object to avoid session dep).</summary>
|
||||
public ClientInfo? ClientInfo { get; set; }
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Reply { get; set; } = string.Empty;
|
||||
public string Request { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EntryOp enum
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Operation type encoded in a JetStream cluster Raft entry.
|
||||
/// Mirrors Go <c>entryOp</c> iota in server/jetstream_cluster.go lines 116-150.
|
||||
/// ONLY ADD TO THE END — inserting values breaks server interoperability.
|
||||
/// </summary>
|
||||
internal enum EntryOp : byte
|
||||
{
|
||||
// Meta ops
|
||||
AssignStreamOp = 0,
|
||||
AssignConsumerOp = 1,
|
||||
RemoveStreamOp = 2,
|
||||
RemoveConsumerOp = 3,
|
||||
// Stream ops
|
||||
StreamMsgOp = 4,
|
||||
PurgeStreamOp = 5,
|
||||
DeleteMsgOp = 6,
|
||||
// Consumer ops
|
||||
UpdateDeliveredOp = 7,
|
||||
UpdateAcksOp = 8,
|
||||
// Compressed consumer assignments
|
||||
AssignCompressedConsumerOp = 9,
|
||||
// Filtered consumer skip
|
||||
UpdateSkipOp = 10,
|
||||
// Update stream
|
||||
UpdateStreamOp = 11,
|
||||
// Pending pull requests
|
||||
AddPendingRequest = 12,
|
||||
RemovePendingRequest = 13,
|
||||
// Compressed streams (RAFT or catchup)
|
||||
CompressedStreamMsgOp = 14,
|
||||
// Deleted gaps on catchups for replicas
|
||||
DeleteRangeOp = 15,
|
||||
// Batch stream ops
|
||||
BatchMsgOp = 16,
|
||||
BatchCommitMsgOp = 17,
|
||||
// Consumer reset to specific starting sequence
|
||||
ResetSeqOp = 18,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RaftGroup
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Describes a Raft consensus group that houses streams and consumers.
|
||||
/// Controlled by the meta-group controller.
|
||||
/// Mirrors Go <c>raftGroup</c> struct in server/jetstream_cluster.go lines 154-163.
|
||||
/// </summary>
|
||||
internal sealed class RaftGroup
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("peers")] public string[] Peers { get; set; } = [];
|
||||
[JsonPropertyName("store")] public StorageType Storage { get; set; }
|
||||
[JsonPropertyName("cluster")] public string? Cluster { get; set; }
|
||||
[JsonPropertyName("preferred")] public string? Preferred { get; set; }
|
||||
[JsonPropertyName("scale_up")] public bool ScaleUp { get; set; }
|
||||
/// <summary>Internal Raft node — not serialized.</summary>
|
||||
[JsonIgnore]
|
||||
public IRaftNode? Node { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamAssignment
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// What the meta controller uses to assign streams to peers.
|
||||
/// Mirrors Go <c>streamAssignment</c> struct in server/jetstream_cluster.go lines 166-184.
|
||||
/// </summary>
|
||||
internal sealed class StreamAssignment
|
||||
{
|
||||
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
|
||||
[JsonPropertyName("created")] public DateTime Created { get; set; }
|
||||
[JsonPropertyName("stream")] public JsonElement ConfigJson { get; set; }
|
||||
[JsonIgnore] public StreamConfig? Config { get; set; }
|
||||
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
|
||||
[JsonPropertyName("sync")] public string Sync { get; set; } = string.Empty;
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
[JsonPropertyName("reply")] public string? Reply { get; set; }
|
||||
[JsonPropertyName("restore_state")] public StreamState? Restore { get; set; }
|
||||
|
||||
// Internal (not serialized)
|
||||
[JsonIgnore] public Dictionary<string, ConsumerAssignment>? Consumers { get; set; }
|
||||
[JsonIgnore] public bool Responded { get; set; }
|
||||
[JsonIgnore] public bool Recovering { get; set; }
|
||||
[JsonIgnore] public bool Reassigning { get; set; }
|
||||
[JsonIgnore] public bool Resetting { get; set; }
|
||||
[JsonIgnore] public Exception? Error { get; set; }
|
||||
[JsonIgnore] public UnsupportedStreamAssignment? Unsupported { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UnsupportedStreamAssignment
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds state for a stream assignment that this server cannot run,
|
||||
/// so that it can still respond to cluster info requests.
|
||||
/// Mirrors Go <c>unsupportedStreamAssignment</c> in server/jetstream_cluster.go lines 186-191.
|
||||
/// </summary>
|
||||
internal sealed class UnsupportedStreamAssignment
|
||||
{
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public StreamInfo Info { get; set; } = new();
|
||||
/// <summary>Internal system client (object to avoid session dep).</summary>
|
||||
public object? SysClient { get; set; }
|
||||
/// <summary>Info subscription (object to avoid session dep).</summary>
|
||||
public object? InfoSub { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConsumerAssignment
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// What the meta controller uses to assign consumers to streams.
|
||||
/// Mirrors Go <c>consumerAssignment</c> struct in server/jetstream_cluster.go lines 250-266.
|
||||
/// </summary>
|
||||
internal sealed class ConsumerAssignment
|
||||
{
|
||||
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
|
||||
[JsonPropertyName("created")] public DateTime Created { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
|
||||
[JsonPropertyName("consumer")] public JsonElement ConfigJson { get; set; }
|
||||
[JsonIgnore] public ConsumerConfig? Config { get; set; }
|
||||
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
[JsonPropertyName("reply")] public string? Reply { get; set; }
|
||||
[JsonPropertyName("state")] public ConsumerState? State { get; set; }
|
||||
|
||||
// Internal (not serialized)
|
||||
[JsonIgnore] public bool Responded { get; set; }
|
||||
[JsonIgnore] public bool Recovering { get; set; }
|
||||
[JsonIgnore] public Exception? Error { get; set; }
|
||||
[JsonIgnore] public UnsupportedConsumerAssignment? Unsupported { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UnsupportedConsumerAssignment
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Holds state for a consumer assignment that this server cannot run.
|
||||
/// Mirrors Go <c>unsupportedConsumerAssignment</c> in server/jetstream_cluster.go lines 268-273.
|
||||
/// </summary>
|
||||
internal sealed class UnsupportedConsumerAssignment
|
||||
{
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public ConsumerInfo Info { get; set; } = new();
|
||||
/// <summary>Internal system client (object to avoid session dep).</summary>
|
||||
public object? SysClient { get; set; }
|
||||
/// <summary>Info subscription (object to avoid session dep).</summary>
|
||||
public object? InfoSub { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WriteableConsumerAssignment
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Serialisable form of a consumer assignment used in meta-snapshots.
|
||||
/// Mirrors Go <c>writeableConsumerAssignment</c> in server/jetstream_cluster.go lines 332-340.
|
||||
/// </summary>
|
||||
internal sealed class WriteableConsumerAssignment
|
||||
{
|
||||
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
|
||||
[JsonPropertyName("created")] public DateTime Created { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
|
||||
[JsonPropertyName("consumer")] public JsonElement ConfigJson { get; set; }
|
||||
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
|
||||
[JsonPropertyName("state")] public ConsumerState? State { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamPurge
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// What the stream leader replicates when purging a stream.
|
||||
/// Mirrors Go <c>streamPurge</c> struct in server/jetstream_cluster.go lines 343-350.
|
||||
/// </summary>
|
||||
internal sealed class StreamPurge
|
||||
{
|
||||
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
|
||||
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
|
||||
[JsonPropertyName("last_seq")] public ulong LastSeq { get; set; }
|
||||
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
|
||||
[JsonPropertyName("reply")] public string Reply { get; set; } = string.Empty;
|
||||
[JsonPropertyName("request")] public JSApiStreamPurgeRequest? Request { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamMsgDelete
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// What the stream leader replicates when deleting a message.
|
||||
/// Mirrors Go <c>streamMsgDelete</c> struct in server/jetstream_cluster.go lines 353-360.
|
||||
/// </summary>
|
||||
internal sealed class StreamMsgDelete
|
||||
{
|
||||
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
|
||||
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
|
||||
[JsonPropertyName("seq")] public ulong Seq { get; set; }
|
||||
[JsonPropertyName("no_erase")] public bool NoErase { get; set; }
|
||||
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
|
||||
[JsonPropertyName("reply")] public string Reply { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RecoveryUpdates
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates stream/consumer changes discovered during meta-recovery so they
|
||||
/// can be applied after the full snapshot has been processed.
|
||||
/// Mirrors Go <c>recoveryUpdates</c> struct in server/jetstream_cluster.go lines 1327-1333.
|
||||
/// </summary>
|
||||
internal sealed class RecoveryUpdates
|
||||
{
|
||||
public Dictionary<string, StreamAssignment> RemoveStreams { get; set; } = new();
|
||||
public Dictionary<string, Dictionary<string, ConsumerAssignment>> RemoveConsumers { get; set; } = new();
|
||||
public Dictionary<string, StreamAssignment> AddStreams { get; set; } = new();
|
||||
public Dictionary<string, StreamAssignment> UpdateStreams { get; set; } = new();
|
||||
public Dictionary<string, Dictionary<string, ConsumerAssignment>> UpdateConsumers { get; set; } = new();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WriteableStreamAssignment
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Serialisable form of a stream assignment (with its consumers) used in meta-snapshots.
|
||||
/// Mirrors Go <c>writeableStreamAssignment</c> in server/jetstream_cluster.go lines 1872-1879.
|
||||
/// </summary>
|
||||
internal sealed class WriteableStreamAssignment
|
||||
{
|
||||
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
|
||||
[JsonPropertyName("created")] public DateTime Created { get; set; }
|
||||
[JsonPropertyName("stream")] public JsonElement ConfigJson { get; set; }
|
||||
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
|
||||
[JsonPropertyName("sync")] public string Sync { get; set; } = string.Empty;
|
||||
public List<WriteableConsumerAssignment> Consumers { get; set; } = new();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConsumerAssignmentResult
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Result sent by a member after processing a consumer assignment.
|
||||
/// Mirrors Go <c>consumerAssignmentResult</c> in server/jetstream_cluster.go lines 5592-5597.
|
||||
/// </summary>
|
||||
internal sealed class ConsumerAssignmentResult
|
||||
{
|
||||
[JsonPropertyName("account")] public string Account { get; set; } = string.Empty;
|
||||
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
|
||||
[JsonPropertyName("consumer")] public string Consumer { get; set; } = string.Empty;
|
||||
/// <summary>Stub: JSApiConsumerCreateResponse — full type in session 20+.</summary>
|
||||
[JsonPropertyName("response")] public object? Response { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamAssignmentResult
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Result sent by a member after processing a stream assignment.
|
||||
/// Mirrors Go <c>streamAssignmentResult</c> in server/jetstream_cluster.go lines 6779-6785.
|
||||
/// </summary>
|
||||
internal sealed class StreamAssignmentResult
|
||||
{
|
||||
[JsonPropertyName("account")] public string Account { get; set; } = string.Empty;
|
||||
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
|
||||
/// <summary>Stub: JSApiStreamCreateResponse — full type in session 20+.</summary>
|
||||
[JsonPropertyName("create_response")] public object? Response { get; set; }
|
||||
/// <summary>Stub: JSApiStreamRestoreResponse — full type in session 20+.</summary>
|
||||
[JsonPropertyName("restore_response")] public object? Restore { get; set; }
|
||||
[JsonPropertyName("is_update")] public bool Update { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SelectPeerError
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Collects the reasons why no suitable peer could be found for a placement.
|
||||
/// Mirrors Go <c>selectPeerError</c> struct in server/jetstream_cluster.go lines 7113-7121.
|
||||
/// </summary>
|
||||
internal sealed class SelectPeerError : Exception
|
||||
{
|
||||
public bool ExcludeTag { get; set; }
|
||||
public bool Offline { get; set; }
|
||||
public bool NoStorage { get; set; }
|
||||
public bool UniqueTag { get; set; }
|
||||
public bool Misc { get; set; }
|
||||
public bool NoJsClust { get; set; }
|
||||
public HashSet<string>? NoMatchTags { get; set; }
|
||||
public HashSet<string>? ExcludeTags { get; set; }
|
||||
|
||||
public override string Message => BuildMessage();
|
||||
|
||||
private string BuildMessage()
|
||||
{
|
||||
var b = new System.Text.StringBuilder("no suitable peers for placement");
|
||||
if (Offline) b.Append(", peer offline");
|
||||
if (ExcludeTag) b.Append(", exclude tag set");
|
||||
if (NoStorage) b.Append(", insufficient storage");
|
||||
if (UniqueTag) b.Append(", server tag not unique");
|
||||
if (Misc) b.Append(", miscellaneous issue");
|
||||
if (NoJsClust) b.Append(", jetstream not enabled in cluster");
|
||||
if (NoMatchTags is { Count: > 0 })
|
||||
{
|
||||
b.Append(", tags not matched [");
|
||||
b.Append(string.Join(", ", NoMatchTags));
|
||||
b.Append(']');
|
||||
}
|
||||
if (ExcludeTags is { Count: > 0 })
|
||||
{
|
||||
b.Append(", tags excluded [");
|
||||
b.Append(string.Join(", ", ExcludeTags));
|
||||
b.Append(']');
|
||||
}
|
||||
return b.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamSnapshot
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialisable snapshot of a stream's state for cluster catchup.
|
||||
/// Mirrors Go <c>streamSnapshot</c> struct in server/jetstream_cluster.go lines 9454-9461.
|
||||
/// </summary>
|
||||
internal sealed class StreamSnapshot
|
||||
{
|
||||
[JsonPropertyName("messages")] public ulong Msgs { get; set; }
|
||||
[JsonPropertyName("bytes")] public ulong Bytes { get; set; }
|
||||
[JsonPropertyName("first_seq")] public ulong FirstSeq { get; set; }
|
||||
[JsonPropertyName("last_seq")] public ulong LastSeq { get; set; }
|
||||
[JsonPropertyName("clfs")] public ulong Failed { get; set; }
|
||||
[JsonPropertyName("deleted")] public ulong[]? Deleted { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StreamSyncRequest
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Request sent by a lagging follower to trigger stream catch-up from the leader.
|
||||
/// Mirrors Go <c>streamSyncRequest</c> struct in server/jetstream_cluster.go lines 9707-9713.
|
||||
/// </summary>
|
||||
internal sealed class StreamSyncRequest
|
||||
{
|
||||
[JsonPropertyName("peer")] public string? Peer { get; set; }
|
||||
[JsonPropertyName("first_seq")] public ulong FirstSeq { get; set; }
|
||||
[JsonPropertyName("last_seq")] public ulong LastSeq { get; set; }
|
||||
[JsonPropertyName("delete_ranges")] public bool DeleteRangesOk { get; set; }
|
||||
[JsonPropertyName("min_applied")] public ulong MinApplied { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stub API request/response types (referenced by cluster types; full impl later)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>Stub: full definition in session 21 (jetstream_api.go).</summary>
|
||||
internal sealed class JSApiStreamPurgeRequest
|
||||
{
|
||||
[JsonPropertyName("filter")] public string? Filter { get; set; }
|
||||
[JsonPropertyName("seq")] public ulong? Seq { get; set; }
|
||||
[JsonPropertyName("keep")] public ulong? Keep { get; set; }
|
||||
}
|
||||
420
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamErrors.cs
Normal file
420
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamErrors.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jetstream_errors.go and server/jetstream_errors_generated.go
|
||||
// in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JsApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Included in all JetStream API responses when there was an error.
|
||||
/// Mirrors <c>ApiError</c> in server/jetstream_errors.go.
|
||||
/// </summary>
|
||||
public sealed class JsApiError
|
||||
{
|
||||
[JsonPropertyName("code")] public int Code { get; set; }
|
||||
[JsonPropertyName("err_code")] public ushort ErrCode { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
|
||||
public override string ToString() => $"{Description} ({ErrCode})";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JsApiErrors — all 203 error code constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Pre-built <see cref="JsApiError"/> instances for all JetStream error codes.
|
||||
/// Mirrors the <c>ApiErrors</c> map in server/jetstream_errors_generated.go.
|
||||
/// </summary>
|
||||
public static class JsApiErrors
|
||||
{
|
||||
public delegate object? ErrorOption();
|
||||
|
||||
// ---- Account ----
|
||||
public static readonly JsApiError AccountResourcesExceeded = new() { Code = 400, ErrCode = 10002, Description = "resource limits exceeded for account" };
|
||||
|
||||
// ---- Atomic Publish ----
|
||||
public static readonly JsApiError AtomicPublishContainsDuplicateMessage = new() { Code = 400, ErrCode = 10201, Description = "atomic publish batch contains duplicate message id" };
|
||||
public static readonly JsApiError AtomicPublishDisabled = new() { Code = 400, ErrCode = 10174, Description = "atomic publish is disabled" };
|
||||
public static readonly JsApiError AtomicPublishIncompleteBatch = new() { Code = 400, ErrCode = 10176, Description = "atomic publish batch is incomplete" };
|
||||
public static readonly JsApiError AtomicPublishInvalidBatchCommit = new() { Code = 400, ErrCode = 10200, Description = "atomic publish batch commit is invalid" };
|
||||
public static readonly JsApiError AtomicPublishInvalidBatchID = new() { Code = 400, ErrCode = 10179, Description = "atomic publish batch ID is invalid" };
|
||||
public static readonly JsApiError AtomicPublishMissingSeq = new() { Code = 400, ErrCode = 10175, Description = "atomic publish sequence is missing" };
|
||||
public static readonly JsApiError AtomicPublishTooLargeBatch = new() { Code = 400, ErrCode = 10199, Description = "atomic publish batch is too large: {size}" };
|
||||
public static readonly JsApiError AtomicPublishUnsupportedHeaderBatch = new() { Code = 400, ErrCode = 10177, Description = "atomic publish unsupported header used: {header}" };
|
||||
|
||||
// ---- General ----
|
||||
public static readonly JsApiError BadRequest = new() { Code = 400, ErrCode = 10003, Description = "bad request" };
|
||||
|
||||
// ---- Cluster ----
|
||||
public static readonly JsApiError ClusterIncomplete = new() { Code = 503, ErrCode = 10004, Description = "incomplete results" };
|
||||
public static readonly JsApiError ClusterNoPeers = new() { Code = 400, ErrCode = 10005, Description = "{err}" };
|
||||
public static readonly JsApiError ClusterNotActive = new() { Code = 500, ErrCode = 10006, Description = "JetStream not in clustered mode" };
|
||||
public static readonly JsApiError ClusterNotAssigned = new() { Code = 500, ErrCode = 10007, Description = "JetStream cluster not assigned to this server" };
|
||||
public static readonly JsApiError ClusterNotAvail = new() { Code = 503, ErrCode = 10008, Description = "JetStream system temporarily unavailable" };
|
||||
public static readonly JsApiError ClusterNotLeader = new() { Code = 500, ErrCode = 10009, Description = "JetStream cluster can not handle request" };
|
||||
public static readonly JsApiError ClusterPeerNotMember = new() { Code = 400, ErrCode = 10040, Description = "peer not a member" };
|
||||
public static readonly JsApiError ClusterRequired = new() { Code = 503, ErrCode = 10010, Description = "JetStream clustering support required" };
|
||||
public static readonly JsApiError ClusterServerMemberChangeInflight = new() { Code = 400, ErrCode = 10202, Description = "cluster member change is in progress" };
|
||||
public static readonly JsApiError ClusterServerNotMember = new() { Code = 400, ErrCode = 10044, Description = "server is not a member of the cluster" };
|
||||
public static readonly JsApiError ClusterTags = new() { Code = 400, ErrCode = 10011, Description = "tags placement not supported for operation" };
|
||||
public static readonly JsApiError ClusterUnSupportFeature = new() { Code = 503, ErrCode = 10036, Description = "not currently supported in clustered mode" };
|
||||
|
||||
// ---- Consumer ----
|
||||
public static readonly JsApiError ConsumerAckPolicyInvalid = new() { Code = 400, ErrCode = 10181, Description = "consumer ack policy invalid" };
|
||||
public static readonly JsApiError ConsumerAckWaitNegative = new() { Code = 400, ErrCode = 10183, Description = "consumer ack wait needs to be positive" };
|
||||
public static readonly JsApiError ConsumerAlreadyExists = new() { Code = 400, ErrCode = 10148, Description = "consumer already exists" };
|
||||
public static readonly JsApiError ConsumerBackOffNegative = new() { Code = 400, ErrCode = 10184, Description = "consumer backoff needs to be positive" };
|
||||
public static readonly JsApiError ConsumerBadDurableName = new() { Code = 400, ErrCode = 10103, Description = "durable name can not contain '.', '*', '>'" };
|
||||
public static readonly JsApiError ConsumerConfigRequired = new() { Code = 400, ErrCode = 10078, Description = "consumer config required" };
|
||||
public static readonly JsApiError ConsumerCreateDurableAndNameMismatch = new() { Code = 400, ErrCode = 10132, Description = "Consumer Durable and Name have to be equal if both are provided" };
|
||||
public static readonly JsApiError ConsumerCreateErr = new() { Code = 500, ErrCode = 10012, Description = "{err}" };
|
||||
public static readonly JsApiError ConsumerCreateFilterSubjectMismatch = new() { Code = 400, ErrCode = 10131, Description = "Consumer create request did not match filtered subject from create subject" };
|
||||
public static readonly JsApiError ConsumerDeliverCycle = new() { Code = 400, ErrCode = 10081, Description = "consumer deliver subject forms a cycle" };
|
||||
public static readonly JsApiError ConsumerDeliverToWildcards = new() { Code = 400, ErrCode = 10079, Description = "consumer deliver subject has wildcards" };
|
||||
public static readonly JsApiError ConsumerDescriptionTooLong = new() { Code = 400, ErrCode = 10107, Description = "consumer description is too long, maximum allowed is {max}" };
|
||||
public static readonly JsApiError ConsumerDirectRequiresEphemeral = new() { Code = 400, ErrCode = 10091, Description = "consumer direct requires an ephemeral consumer" };
|
||||
public static readonly JsApiError ConsumerDirectRequiresPush = new() { Code = 400, ErrCode = 10090, Description = "consumer direct requires a push based consumer" };
|
||||
public static readonly JsApiError ConsumerDoesNotExist = new() { Code = 400, ErrCode = 10149, Description = "consumer does not exist" };
|
||||
public static readonly JsApiError ConsumerDuplicateFilterSubjects = new() { Code = 400, ErrCode = 10136, Description = "consumer cannot have both FilterSubject and FilterSubjects specified" };
|
||||
public static readonly JsApiError ConsumerDurableNameNotInSubject = new() { Code = 400, ErrCode = 10016, Description = "consumer expected to be durable but no durable name set in subject" };
|
||||
public static readonly JsApiError ConsumerDurableNameNotMatchSubject = new() { Code = 400, ErrCode = 10017, Description = "consumer name in subject does not match durable name in request" };
|
||||
public static readonly JsApiError ConsumerDurableNameNotSet = new() { Code = 400, ErrCode = 10018, Description = "consumer expected to be durable but a durable name was not set" };
|
||||
public static readonly JsApiError ConsumerEmptyFilter = new() { Code = 400, ErrCode = 10139, Description = "consumer filter in FilterSubjects cannot be empty" };
|
||||
public static readonly JsApiError ConsumerEmptyGroupName = new() { Code = 400, ErrCode = 10161, Description = "Group name cannot be an empty string" };
|
||||
public static readonly JsApiError ConsumerEphemeralWithDurableInSubject = new() { Code = 400, ErrCode = 10019, Description = "consumer expected to be ephemeral but detected a durable name set in subject" };
|
||||
public static readonly JsApiError ConsumerEphemeralWithDurableName = new() { Code = 400, ErrCode = 10020, Description = "consumer expected to be ephemeral but a durable name was set in request" };
|
||||
public static readonly JsApiError ConsumerExistingActive = new() { Code = 400, ErrCode = 10105, Description = "consumer already exists and is still active" };
|
||||
public static readonly JsApiError ConsumerFCRequiresPush = new() { Code = 400, ErrCode = 10089, Description = "consumer flow control requires a push based consumer" };
|
||||
public static readonly JsApiError ConsumerFilterNotSubset = new() { Code = 400, ErrCode = 10093, Description = "consumer filter subject is not a valid subset of the interest subjects" };
|
||||
public static readonly JsApiError ConsumerHBRequiresPush = new() { Code = 400, ErrCode = 10088, Description = "consumer idle heartbeat requires a push based consumer" };
|
||||
public static readonly JsApiError ConsumerInactiveThresholdExcess = new() { Code = 400, ErrCode = 10153, Description = "consumer inactive threshold exceeds system limit of {limit}" };
|
||||
public static readonly JsApiError ConsumerInvalidDeliverSubject = new() { Code = 400, ErrCode = 10112, Description = "invalid push consumer deliver subject" };
|
||||
public static readonly JsApiError ConsumerInvalidGroupName = new() { Code = 400, ErrCode = 10162, Description = "Valid priority group name must match A-Z, a-z, 0-9, -_/=)+ and may not exceed 16 characters" };
|
||||
public static readonly JsApiError ConsumerInvalidPolicy = new() { Code = 400, ErrCode = 10094, Description = "{err}" };
|
||||
public static readonly JsApiError ConsumerInvalidPriorityGroup = new() { Code = 400, ErrCode = 10160, Description = "Provided priority group does not exist for this consumer" };
|
||||
public static readonly JsApiError ConsumerInvalidReset = new() { Code = 400, ErrCode = 10204, Description = "invalid reset: {err}" };
|
||||
public static readonly JsApiError ConsumerInvalidSampling = new() { Code = 400, ErrCode = 10095, Description = "failed to parse consumer sampling configuration: {err}" };
|
||||
public static readonly JsApiError ConsumerMaxDeliverBackoff = new() { Code = 400, ErrCode = 10116, Description = "max deliver is required to be > length of backoff values" };
|
||||
public static readonly JsApiError ConsumerMaxPendingAckExcess = new() { Code = 400, ErrCode = 10121, Description = "consumer max ack pending exceeds system limit of {limit}" };
|
||||
public static readonly JsApiError ConsumerMaxPendingAckPolicyRequired = new() { Code = 400, ErrCode = 10082, Description = "consumer requires ack policy for max ack pending" };
|
||||
public static readonly JsApiError ConsumerMaxRequestBatchExceeded = new() { Code = 400, ErrCode = 10125, Description = "consumer max request batch exceeds server limit of {limit}" };
|
||||
public static readonly JsApiError ConsumerMaxRequestBatchNegative = new() { Code = 400, ErrCode = 10114, Description = "consumer max request batch needs to be > 0" };
|
||||
public static readonly JsApiError ConsumerMaxRequestExpiresTooSmall = new() { Code = 400, ErrCode = 10115, Description = "consumer max request expires needs to be >= 1ms" };
|
||||
public static readonly JsApiError ConsumerMaxWaitingNegative = new() { Code = 400, ErrCode = 10087, Description = "consumer max waiting needs to be positive" };
|
||||
public static readonly JsApiError ConsumerMetadataLength = new() { Code = 400, ErrCode = 10135, Description = "consumer metadata exceeds maximum size of {limit}" };
|
||||
public static readonly JsApiError ConsumerMultipleFiltersNotAllowed = new() { Code = 400, ErrCode = 10137, Description = "consumer with multiple subject filters cannot use subject based API" };
|
||||
public static readonly JsApiError ConsumerNameContainsPathSeparators = new() { Code = 400, ErrCode = 10127, Description = "Consumer name can not contain path separators" };
|
||||
public static readonly JsApiError ConsumerNameExist = new() { Code = 400, ErrCode = 10013, Description = "consumer name already in use" };
|
||||
public static readonly JsApiError ConsumerNameTooLong = new() { Code = 400, ErrCode = 10102, Description = "consumer name is too long, maximum allowed is {max}" };
|
||||
public static readonly JsApiError ConsumerNotFound = new() { Code = 404, ErrCode = 10014, Description = "consumer not found" };
|
||||
public static readonly JsApiError ConsumerOffline = new() { Code = 500, ErrCode = 10119, Description = "consumer is offline" };
|
||||
public static readonly JsApiError ConsumerOfflineReason = new() { Code = 500, ErrCode = 10195, Description = "consumer is offline: {err}" };
|
||||
public static readonly JsApiError ConsumerOnMapped = new() { Code = 400, ErrCode = 10092, Description = "consumer direct on a mapped consumer" };
|
||||
public static readonly JsApiError ConsumerOverlappingSubjectFilters = new() { Code = 400, ErrCode = 10138, Description = "consumer subject filters cannot overlap" };
|
||||
public static readonly JsApiError ConsumerPinnedTTLWithoutPriorityPolicyNone = new() { Code = 400, ErrCode = 10197, Description = "PinnedTTL cannot be set when PriorityPolicy is none" };
|
||||
public static readonly JsApiError ConsumerPriorityGroupWithPolicyNone = new() { Code = 400, ErrCode = 10196, Description = "consumer can not have priority groups when policy is none" };
|
||||
public static readonly JsApiError ConsumerPriorityPolicyWithoutGroup = new() { Code = 400, ErrCode = 10159, Description = "Setting PriorityPolicy requires at least one PriorityGroup to be set" };
|
||||
public static readonly JsApiError ConsumerPullNotDurable = new() { Code = 400, ErrCode = 10085, Description = "consumer in pull mode requires a durable name" };
|
||||
public static readonly JsApiError ConsumerPullRequiresAck = new() { Code = 400, ErrCode = 10084, Description = "consumer in pull mode requires explicit ack policy on workqueue stream" };
|
||||
public static readonly JsApiError ConsumerPullWithRateLimit = new() { Code = 400, ErrCode = 10086, Description = "consumer in pull mode can not have rate limit set" };
|
||||
public static readonly JsApiError ConsumerPushMaxWaiting = new() { Code = 400, ErrCode = 10080, Description = "consumer in push mode can not set max waiting" };
|
||||
public static readonly JsApiError ConsumerPushWithPriorityGroup = new() { Code = 400, ErrCode = 10178, Description = "priority groups can not be used with push consumers" };
|
||||
public static readonly JsApiError ConsumerReplacementWithDifferentName = new() { Code = 400, ErrCode = 10106, Description = "consumer replacement durable config not the same" };
|
||||
public static readonly JsApiError ConsumerReplayPolicyInvalid = new() { Code = 400, ErrCode = 10182, Description = "consumer replay policy invalid" };
|
||||
public static readonly JsApiError ConsumerReplicasExceedsStream = new() { Code = 400, ErrCode = 10126, Description = "consumer config replica count exceeds parent stream" };
|
||||
public static readonly JsApiError ConsumerReplicasShouldMatchStream = new() { Code = 400, ErrCode = 10134, Description = "consumer config replicas must match interest retention stream's replicas" };
|
||||
public static readonly JsApiError ConsumerSmallHeartbeat = new() { Code = 400, ErrCode = 10083, Description = "consumer idle heartbeat needs to be >= 100ms" };
|
||||
public static readonly JsApiError ConsumerStoreFailed = new() { Code = 500, ErrCode = 10104, Description = "error creating store for consumer: {err}" };
|
||||
public static readonly JsApiError ConsumerWQConsumerNotDeliverAll = new() { Code = 400, ErrCode = 10101, Description = "consumer must be deliver all on workqueue stream" };
|
||||
public static readonly JsApiError ConsumerWQConsumerNotUnique = new() { Code = 400, ErrCode = 10100, Description = "filtered consumer not unique on workqueue stream" };
|
||||
public static readonly JsApiError ConsumerWQMultipleUnfiltered = new() { Code = 400, ErrCode = 10099, Description = "multiple non-filtered consumers not allowed on workqueue stream" };
|
||||
public static readonly JsApiError ConsumerWQRequiresExplicitAck = new() { Code = 400, ErrCode = 10098, Description = "workqueue stream requires explicit ack" };
|
||||
public static readonly JsApiError ConsumerWithFlowControlNeedsHeartbeats = new() { Code = 400, ErrCode = 10108, Description = "consumer with flow control also needs heartbeats" };
|
||||
|
||||
// ---- Resources ----
|
||||
public static readonly JsApiError InsufficientResources = new() { Code = 503, ErrCode = 10023, Description = "insufficient resources" };
|
||||
public static readonly JsApiError InvalidJSON = new() { Code = 400, ErrCode = 10025, Description = "invalid JSON: {err}" };
|
||||
public static readonly JsApiError MaximumConsumersLimit = new() { Code = 400, ErrCode = 10026, Description = "maximum consumers limit reached" };
|
||||
public static readonly JsApiError MaximumStreamsLimit = new() { Code = 400, ErrCode = 10027, Description = "maximum number of streams reached" };
|
||||
public static readonly JsApiError MemoryResourcesExceeded = new() { Code = 500, ErrCode = 10028, Description = "insufficient memory resources available" };
|
||||
|
||||
// ---- Message Counter ----
|
||||
public static readonly JsApiError MessageCounterBroken = new() { Code = 400, ErrCode = 10172, Description = "message counter is broken" };
|
||||
public static readonly JsApiError MessageIncrDisabled = new() { Code = 400, ErrCode = 10168, Description = "message counters is disabled" };
|
||||
public static readonly JsApiError MessageIncrInvalid = new() { Code = 400, ErrCode = 10171, Description = "message counter increment is invalid" };
|
||||
public static readonly JsApiError MessageIncrMissing = new() { Code = 400, ErrCode = 10169, Description = "message counter increment is missing" };
|
||||
public static readonly JsApiError MessageIncrPayload = new() { Code = 400, ErrCode = 10170, Description = "message counter has payload" };
|
||||
|
||||
// ---- Message Schedules ----
|
||||
public static readonly JsApiError MessageSchedulesDisabled = new() { Code = 400, ErrCode = 10188, Description = "message schedules is disabled" };
|
||||
public static readonly JsApiError MessageSchedulesPatternInvalid = new() { Code = 400, ErrCode = 10189, Description = "message schedules pattern is invalid" };
|
||||
public static readonly JsApiError MessageSchedulesRollupInvalid = new() { Code = 400, ErrCode = 10192, Description = "message schedules invalid rollup" };
|
||||
public static readonly JsApiError MessageSchedulesSourceInvalid = new() { Code = 400, ErrCode = 10203, Description = "message schedules source is invalid" };
|
||||
public static readonly JsApiError MessageSchedulesTTLInvalid = new() { Code = 400, ErrCode = 10191, Description = "message schedules invalid per-message TTL" };
|
||||
public static readonly JsApiError MessageSchedulesTargetInvalid = new() { Code = 400, ErrCode = 10190, Description = "message schedules target is invalid" };
|
||||
|
||||
// ---- Message TTL ----
|
||||
public static readonly JsApiError MessageTTLDisabled = new() { Code = 400, ErrCode = 10166, Description = "per-message TTL is disabled" };
|
||||
public static readonly JsApiError MessageTTLInvalid = new() { Code = 400, ErrCode = 10165, Description = "invalid per-message TTL" };
|
||||
|
||||
// ---- Mirror ----
|
||||
public static readonly JsApiError MirrorConsumerSetupFailed = new() { Code = 500, ErrCode = 10029, Description = "{err}" };
|
||||
public static readonly JsApiError MirrorInvalidStreamName = new() { Code = 400, ErrCode = 10142, Description = "mirrored stream name is invalid" };
|
||||
public static readonly JsApiError MirrorInvalidSubjectFilter = new() { Code = 400, ErrCode = 10151, Description = "mirror transform source: {err}" };
|
||||
public static readonly JsApiError MirrorInvalidTransformDestination = new() { Code = 400, ErrCode = 10154, Description = "mirror transform: {err}" };
|
||||
public static readonly JsApiError MirrorMaxMessageSizeTooBig = new() { Code = 400, ErrCode = 10030, Description = "stream mirror must have max message size >= source" };
|
||||
public static readonly JsApiError MirrorMultipleFiltersNotAllowed = new() { Code = 400, ErrCode = 10150, Description = "mirror with multiple subject transforms cannot also have a single subject filter" };
|
||||
public static readonly JsApiError MirrorOverlappingSubjectFilters = new() { Code = 400, ErrCode = 10152, Description = "mirror subject filters can not overlap" };
|
||||
public static readonly JsApiError MirrorWithAtomicPublish = new() { Code = 400, ErrCode = 10198, Description = "stream mirrors can not also use atomic publishing" };
|
||||
public static readonly JsApiError MirrorWithCounters = new() { Code = 400, ErrCode = 10173, Description = "stream mirrors can not also calculate counters" };
|
||||
public static readonly JsApiError MirrorWithFirstSeq = new() { Code = 400, ErrCode = 10143, Description = "stream mirrors can not have first sequence configured" };
|
||||
public static readonly JsApiError MirrorWithMsgSchedules = new() { Code = 400, ErrCode = 10186, Description = "stream mirrors can not also schedule messages" };
|
||||
public static readonly JsApiError MirrorWithSources = new() { Code = 400, ErrCode = 10031, Description = "stream mirrors can not also contain other sources" };
|
||||
public static readonly JsApiError MirrorWithStartSeqAndTime = new() { Code = 400, ErrCode = 10032, Description = "stream mirrors can not have both start seq and start time configured" };
|
||||
public static readonly JsApiError MirrorWithSubjectFilters = new() { Code = 400, ErrCode = 10033, Description = "stream mirrors can not contain filtered subjects" };
|
||||
public static readonly JsApiError MirrorWithSubjects = new() { Code = 400, ErrCode = 10034, Description = "stream mirrors can not contain subjects" };
|
||||
|
||||
// ---- Misc ----
|
||||
public static readonly JsApiError NoAccount = new() { Code = 503, ErrCode = 10035, Description = "account not found" };
|
||||
public static readonly JsApiError NoLimits = new() { Code = 400, ErrCode = 10120, Description = "no JetStream default or applicable tiered limit present" };
|
||||
public static readonly JsApiError NoMessageFound = new() { Code = 404, ErrCode = 10037, Description = "no message found" };
|
||||
public static readonly JsApiError NotEmptyRequest = new() { Code = 400, ErrCode = 10038, Description = "expected an empty request payload" };
|
||||
public static readonly JsApiError NotEnabled = new() { Code = 503, ErrCode = 10076, Description = "JetStream not enabled" };
|
||||
public static readonly JsApiError NotEnabledForAccount = new() { Code = 503, ErrCode = 10039, Description = "JetStream not enabled for account" };
|
||||
public static readonly JsApiError Pedantic = new() { Code = 400, ErrCode = 10157, Description = "pedantic mode: {err}" };
|
||||
public static readonly JsApiError PeerRemap = new() { Code = 503, ErrCode = 10075, Description = "peer remap failed" };
|
||||
|
||||
// ---- RAFT ----
|
||||
public static readonly JsApiError RaftGeneralErr = new() { Code = 500, ErrCode = 10041, Description = "{err}" };
|
||||
|
||||
// ---- Replicas ----
|
||||
public static readonly JsApiError ReplicasCountCannotBeNegative = new() { Code = 400, ErrCode = 10133, Description = "replicas count cannot be negative" };
|
||||
public static readonly JsApiError RequiredApiLevel = new() { Code = 412, ErrCode = 10185, Description = "JetStream minimum api level required" };
|
||||
|
||||
// ---- Restore ----
|
||||
public static readonly JsApiError RestoreSubscribeFailed = new() { Code = 500, ErrCode = 10042, Description = "JetStream unable to subscribe to restore snapshot {subject}: {err}" };
|
||||
|
||||
// ---- Sequence ----
|
||||
public static readonly JsApiError SequenceNotFound = new() { Code = 400, ErrCode = 10043, Description = "sequence {seq} not found" };
|
||||
public static readonly JsApiError SnapshotDeliverSubjectInvalid = new() { Code = 400, ErrCode = 10015, Description = "deliver subject not valid" };
|
||||
|
||||
// ---- Source ----
|
||||
public static readonly JsApiError SourceConsumerSetupFailed = new() { Code = 500, ErrCode = 10045, Description = "{err}" };
|
||||
public static readonly JsApiError SourceDuplicateDetected = new() { Code = 400, ErrCode = 10140, Description = "duplicate source configuration detected" };
|
||||
public static readonly JsApiError SourceInvalidStreamName = new() { Code = 400, ErrCode = 10141, Description = "sourced stream name is invalid" };
|
||||
public static readonly JsApiError SourceInvalidSubjectFilter = new() { Code = 400, ErrCode = 10145, Description = "source transform source: {err}" };
|
||||
public static readonly JsApiError SourceInvalidTransformDestination = new() { Code = 400, ErrCode = 10146, Description = "source transform: {err}" };
|
||||
public static readonly JsApiError SourceMaxMessageSizeTooBig = new() { Code = 400, ErrCode = 10046, Description = "stream source must have max message size >= target" };
|
||||
public static readonly JsApiError SourceMultipleFiltersNotAllowed = new() { Code = 400, ErrCode = 10144, Description = "source with multiple subject transforms cannot also have a single subject filter" };
|
||||
public static readonly JsApiError SourceOverlappingSubjectFilters = new() { Code = 400, ErrCode = 10147, Description = "source filters can not overlap" };
|
||||
public static readonly JsApiError SourceWithMsgSchedules = new() { Code = 400, ErrCode = 10187, Description = "stream source can not also schedule messages" };
|
||||
|
||||
// ---- Storage ----
|
||||
public static readonly JsApiError StorageResourcesExceeded = new() { Code = 500, ErrCode = 10047, Description = "insufficient storage resources available" };
|
||||
|
||||
// ---- Stream ----
|
||||
public static readonly JsApiError StreamAssignment = new() { Code = 500, ErrCode = 10048, Description = "{err}" };
|
||||
public static readonly JsApiError StreamCreate = new() { Code = 500, ErrCode = 10049, Description = "{err}" };
|
||||
public static readonly JsApiError StreamDelete = new() { Code = 500, ErrCode = 10050, Description = "{err}" };
|
||||
public static readonly JsApiError StreamDuplicateMessageConflict = new() { Code = 409, ErrCode = 10158, Description = "duplicate message id is in process" };
|
||||
public static readonly JsApiError StreamExpectedLastSeqPerSubjectInvalid = new() { Code = 400, ErrCode = 10193, Description = "missing sequence for expected last sequence per subject" };
|
||||
public static readonly JsApiError StreamExpectedLastSeqPerSubjectNotReady = new() { Code = 503, ErrCode = 10163, Description = "expected last sequence per subject temporarily unavailable" };
|
||||
public static readonly JsApiError StreamExternalApiOverlap = new() { Code = 400, ErrCode = 10021, Description = "stream external api prefix {prefix} must not overlap with {subject}" };
|
||||
public static readonly JsApiError StreamExternalDelPrefixOverlaps = new() { Code = 400, ErrCode = 10022, Description = "stream external delivery prefix {prefix} overlaps with stream subject {subject}" };
|
||||
public static readonly JsApiError StreamGeneralError = new() { Code = 500, ErrCode = 10051, Description = "{err}" };
|
||||
public static readonly JsApiError StreamHeaderExceedsMaximum = new() { Code = 400, ErrCode = 10097, Description = "header size exceeds maximum allowed of 64k" };
|
||||
public static readonly JsApiError StreamInfoMaxSubjects = new() { Code = 500, ErrCode = 10117, Description = "subject details would exceed maximum allowed" };
|
||||
public static readonly JsApiError StreamInvalidConfig = new() { Code = 500, ErrCode = 10052, Description = "{err}" };
|
||||
public static readonly JsApiError StreamInvalid = new() { Code = 500, ErrCode = 10096, Description = "stream not valid" };
|
||||
public static readonly JsApiError StreamInvalidExternalDeliverySubj = new() { Code = 400, ErrCode = 10024, Description = "stream external delivery prefix {prefix} must not contain wildcards" };
|
||||
public static readonly JsApiError StreamLimits = new() { Code = 500, ErrCode = 10053, Description = "{err}" };
|
||||
public static readonly JsApiError StreamMaxBytesRequired = new() { Code = 400, ErrCode = 10113, Description = "account requires a stream config to have max bytes set" };
|
||||
public static readonly JsApiError StreamMaxStreamBytesExceeded = new() { Code = 400, ErrCode = 10122, Description = "stream max bytes exceeds account limit max stream bytes" };
|
||||
public static readonly JsApiError StreamMessageExceedsMaximum = new() { Code = 400, ErrCode = 10054, Description = "message size exceeds maximum allowed" };
|
||||
public static readonly JsApiError StreamMinLastSeq = new() { Code = 412, ErrCode = 10180, Description = "min last sequence" };
|
||||
public static readonly JsApiError StreamMirrorNotUpdatable = new() { Code = 400, ErrCode = 10055, Description = "stream mirror configuration can not be updated" };
|
||||
public static readonly JsApiError StreamMismatch = new() { Code = 400, ErrCode = 10056, Description = "stream name in subject does not match request" };
|
||||
public static readonly JsApiError StreamMoveAndScale = new() { Code = 400, ErrCode = 10123, Description = "can not move and scale a stream in a single update" };
|
||||
public static readonly JsApiError StreamMoveInProgress = new() { Code = 400, ErrCode = 10124, Description = "stream move already in progress: {msg}" };
|
||||
public static readonly JsApiError StreamMoveNotInProgress = new() { Code = 400, ErrCode = 10129, Description = "stream move not in progress" };
|
||||
public static readonly JsApiError StreamMsgDeleteFailed = new() { Code = 500, ErrCode = 10057, Description = "{err}" };
|
||||
public static readonly JsApiError StreamNameContainsPathSeparators = new() { Code = 400, ErrCode = 10128, Description = "Stream name can not contain path separators" };
|
||||
public static readonly JsApiError StreamNameExist = new() { Code = 400, ErrCode = 10058, Description = "stream name already in use with a different configuration" };
|
||||
public static readonly JsApiError StreamNameExistRestoreFailed = new() { Code = 400, ErrCode = 10130, Description = "stream name already in use, cannot restore" };
|
||||
public static readonly JsApiError StreamNotFound = new() { Code = 404, ErrCode = 10059, Description = "stream not found" };
|
||||
public static readonly JsApiError StreamNotMatch = new() { Code = 400, ErrCode = 10060, Description = "expected stream does not match" };
|
||||
public static readonly JsApiError StreamOffline = new() { Code = 500, ErrCode = 10118, Description = "stream is offline" };
|
||||
public static readonly JsApiError StreamOfflineReason = new() { Code = 500, ErrCode = 10194, Description = "stream is offline: {err}" };
|
||||
public static readonly JsApiError StreamPurgeFailed = new() { Code = 500, ErrCode = 10110, Description = "{err}" };
|
||||
public static readonly JsApiError StreamReplicasNotSupported = new() { Code = 500, ErrCode = 10074, Description = "replicas > 1 not supported in non-clustered mode" };
|
||||
public static readonly JsApiError StreamReplicasNotUpdatable = new() { Code = 400, ErrCode = 10061, Description = "Replicas configuration can not be updated" };
|
||||
public static readonly JsApiError StreamRestore = new() { Code = 500, ErrCode = 10062, Description = "restore failed: {err}" };
|
||||
public static readonly JsApiError StreamRollupFailed = new() { Code = 500, ErrCode = 10111, Description = "{err}" };
|
||||
public static readonly JsApiError StreamSealed = new() { Code = 400, ErrCode = 10109, Description = "invalid operation on sealed stream" };
|
||||
public static readonly JsApiError StreamSequenceNotMatch = new() { Code = 503, ErrCode = 10063, Description = "expected stream sequence does not match" };
|
||||
public static readonly JsApiError StreamSnapshot = new() { Code = 500, ErrCode = 10064, Description = "snapshot failed: {err}" };
|
||||
public static readonly JsApiError StreamStoreFailed = new() { Code = 503, ErrCode = 10077, Description = "{err}" };
|
||||
public static readonly JsApiError StreamSubjectOverlap = new() { Code = 400, ErrCode = 10065, Description = "subjects overlap with an existing stream" };
|
||||
public static readonly JsApiError StreamTemplateCreate = new() { Code = 500, ErrCode = 10066, Description = "{err}" };
|
||||
public static readonly JsApiError StreamTemplateDelete = new() { Code = 500, ErrCode = 10067, Description = "{err}" };
|
||||
public static readonly JsApiError StreamTemplateNotFound = new() { Code = 404, ErrCode = 10068, Description = "template not found" };
|
||||
public static readonly JsApiError StreamTooManyRequests = new() { Code = 429, ErrCode = 10167, Description = "too many requests" };
|
||||
public static readonly JsApiError StreamTransformInvalidDestination = new() { Code = 400, ErrCode = 10156, Description = "stream transform: {err}" };
|
||||
public static readonly JsApiError StreamTransformInvalidSource = new() { Code = 400, ErrCode = 10155, Description = "stream transform source: {err}" };
|
||||
public static readonly JsApiError StreamUpdate = new() { Code = 500, ErrCode = 10069, Description = "{err}" };
|
||||
public static readonly JsApiError StreamWrongLastMsgID = new() { Code = 400, ErrCode = 10070, Description = "wrong last msg ID: {id}" };
|
||||
public static readonly JsApiError StreamWrongLastSequenceConstant = new() { Code = 400, ErrCode = 10164, Description = "wrong last sequence" };
|
||||
public static readonly JsApiError StreamWrongLastSequence = new() { Code = 400, ErrCode = 10071, Description = "wrong last sequence: {seq}" };
|
||||
|
||||
// ---- Temp storage ----
|
||||
public static readonly JsApiError TempStorageFailed = new() { Code = 500, ErrCode = 10072, Description = "JetStream unable to open temp storage for restore" };
|
||||
public static readonly JsApiError TemplateNameNotMatchSubject = new() { Code = 400, ErrCode = 10073, Description = "template name in subject does not match request" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lookup by ErrCode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static readonly Dictionary<ushort, JsApiError> _byErrCode;
|
||||
|
||||
static JsApiErrors()
|
||||
{
|
||||
_byErrCode = new Dictionary<ushort, JsApiError>();
|
||||
foreach (var field in typeof(JsApiErrors).GetFields(
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||||
{
|
||||
if (field.GetValue(null) is JsApiError e)
|
||||
_byErrCode.TryAdd(e.ErrCode, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-built <see cref="JsApiError"/> for the given err_code, or null if not found.
|
||||
/// </summary>
|
||||
public static JsApiError? ForErrCode(ushort errCode) =>
|
||||
_byErrCode.TryGetValue(errCode, out var e) ? e : null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given <see cref="JsApiError"/> matches one or more of the supplied err_codes.
|
||||
/// Mirrors <c>IsNatsErr</c> in server/jetstream_errors.go.
|
||||
/// </summary>
|
||||
public static bool IsNatsError(JsApiError? err, params ushort[] errCodes)
|
||||
{
|
||||
return IsNatsErr(err, errCodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="err"/> is a <see cref="JsApiError"/> and matches one of the supplied IDs.
|
||||
/// Unknown IDs are ignored, matching Go's map-based lookup behavior.
|
||||
/// </summary>
|
||||
public static bool IsNatsErr(object? err, params ushort[] ids)
|
||||
{
|
||||
if (err is not JsApiError ce)
|
||||
return false;
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var ae = ForErrCode(id);
|
||||
if (ae != null && ce.ErrCode == ae.ErrCode)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats an API error string exactly as Go <c>ApiError.Error()</c>.
|
||||
/// </summary>
|
||||
public static string Error(JsApiError? err) => err?.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an option that causes constructor helpers to return the provided
|
||||
/// <see cref="JsApiError"/> when present.
|
||||
/// Mirrors Go <c>Unless</c>.
|
||||
/// </summary>
|
||||
public static ErrorOption Unless(object? err) => () => err;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors Go <c>NewJSRestoreSubscribeFailedError</c>.
|
||||
/// </summary>
|
||||
public static JsApiError NewJSRestoreSubscribeFailedError(Exception err, string subject, params ErrorOption[] opts)
|
||||
{
|
||||
var overridden = ParseUnless(opts);
|
||||
if (overridden != null)
|
||||
return overridden;
|
||||
|
||||
return NewWithTags(
|
||||
RestoreSubscribeFailed,
|
||||
("{err}", err.Message),
|
||||
("{subject}", subject));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors Go <c>NewJSStreamRestoreError</c>.
|
||||
/// </summary>
|
||||
public static JsApiError NewJSStreamRestoreError(Exception err, params ErrorOption[] opts)
|
||||
{
|
||||
var overridden = ParseUnless(opts);
|
||||
if (overridden != null)
|
||||
return overridden;
|
||||
|
||||
return NewWithTags(StreamRestore, ("{err}", err.Message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors Go <c>NewJSPeerRemapError</c>.
|
||||
/// </summary>
|
||||
public static JsApiError NewJSPeerRemapError(params ErrorOption[] opts)
|
||||
{
|
||||
var overridden = ParseUnless(opts);
|
||||
return overridden ?? Clone(PeerRemap);
|
||||
}
|
||||
|
||||
private static JsApiError? ParseUnless(ReadOnlySpan<ErrorOption> opts)
|
||||
{
|
||||
foreach (var opt in opts)
|
||||
{
|
||||
var value = opt();
|
||||
if (value is JsApiError apiErr)
|
||||
return Clone(apiErr);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsApiError Clone(JsApiError source) => new()
|
||||
{
|
||||
Code = source.Code,
|
||||
ErrCode = source.ErrCode,
|
||||
Description = source.Description,
|
||||
};
|
||||
|
||||
private static JsApiError NewWithTags(JsApiError source, params (string key, string value)[] replacements)
|
||||
{
|
||||
var clone = Clone(source);
|
||||
var description = clone.Description ?? string.Empty;
|
||||
|
||||
foreach (var (key, value) in replacements)
|
||||
description = description.Replace(key, value, StringComparison.Ordinal);
|
||||
|
||||
clone.Description = description;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
399
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamTypes.cs
Normal file
399
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamTypes.cs
Normal file
@@ -0,0 +1,399 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jetstream.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStreamConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the JetStream subsystem.
|
||||
/// Mirrors <c>JetStreamConfig</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamConfig
|
||||
{
|
||||
/// <summary>Maximum size of memory-backed streams.</summary>
|
||||
[JsonPropertyName("max_memory")]
|
||||
public long MaxMemory { get; set; }
|
||||
|
||||
/// <summary>Maximum size of file-backed streams.</summary>
|
||||
[JsonPropertyName("max_storage")]
|
||||
public long MaxStore { get; set; }
|
||||
|
||||
/// <summary>Directory where storage files are stored.</summary>
|
||||
[JsonPropertyName("store_dir")]
|
||||
public string StoreDir { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>How frequently we sync to disk in the background via fsync.</summary>
|
||||
[JsonPropertyName("sync_interval")]
|
||||
public TimeSpan SyncInterval { get; set; }
|
||||
|
||||
/// <summary>If true, flushes are done after every write.</summary>
|
||||
[JsonPropertyName("sync_always")]
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
/// <summary>The JetStream domain for this server.</summary>
|
||||
[JsonPropertyName("domain")]
|
||||
public string Domain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether compression is supported.</summary>
|
||||
[JsonPropertyName("compress_ok")]
|
||||
public bool CompressOK { get; set; }
|
||||
|
||||
/// <summary>Unique tag assigned to this server instance.</summary>
|
||||
[JsonPropertyName("unique_tag")]
|
||||
public string UniqueTag { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether strict JSON parsing is performed.</summary>
|
||||
[JsonPropertyName("strict")]
|
||||
public bool Strict { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStreamApiStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the JetStream API usage for this server.
|
||||
/// Mirrors <c>JetStreamAPIStats</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamApiStats
|
||||
{
|
||||
/// <summary>Active API level implemented by this server.</summary>
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; set; }
|
||||
|
||||
/// <summary>Total API requests received since server start.</summary>
|
||||
[JsonPropertyName("total")]
|
||||
public ulong Total { get; set; }
|
||||
|
||||
/// <summary>Total API requests that resulted in error responses.</summary>
|
||||
[JsonPropertyName("errors")]
|
||||
public ulong Errors { get; set; }
|
||||
|
||||
/// <summary>Number of API requests currently being served.</summary>
|
||||
[JsonPropertyName("inflight")]
|
||||
public ulong Inflight { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStreamStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about JetStream for this server.
|
||||
/// Mirrors <c>JetStreamStats</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamStats
|
||||
{
|
||||
[JsonPropertyName("memory")] public ulong Memory { get; set; }
|
||||
[JsonPropertyName("storage")] public ulong Store { get; set; }
|
||||
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
|
||||
[JsonPropertyName("reserved_storage")] public ulong ReservedStore { get; set; }
|
||||
[JsonPropertyName("accounts")] public int Accounts { get; set; }
|
||||
[JsonPropertyName("ha_assets")] public int HAAssets { get; set; }
|
||||
[JsonPropertyName("api")] public JetStreamApiStats Api { get; set; } = new();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStreamAccountLimits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Per-account JetStream limits.
|
||||
/// Mirrors <c>JetStreamAccountLimits</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamAccountLimits
|
||||
{
|
||||
[JsonPropertyName("max_memory")] public long MaxMemory { get; set; }
|
||||
[JsonPropertyName("max_storage")] public long MaxStore { get; set; }
|
||||
[JsonPropertyName("max_streams")] public int MaxStreams { get; set; }
|
||||
[JsonPropertyName("max_consumers")] public int MaxConsumers { get; set; }
|
||||
[JsonPropertyName("max_ack_pending")] public int MaxAckPending { get; set; }
|
||||
[JsonPropertyName("memory_max_stream_bytes")] public long MemoryMaxStreamBytes { get; set; }
|
||||
[JsonPropertyName("storage_max_stream_bytes")] public long StoreMaxStreamBytes { get; set; }
|
||||
[JsonPropertyName("max_bytes_required")] public bool MaxBytesRequired { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStreamTier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Per-tier JetStream usage and limits.
|
||||
/// Mirrors <c>JetStreamTier</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamTier
|
||||
{
|
||||
[JsonPropertyName("memory")] public ulong Memory { get; set; }
|
||||
[JsonPropertyName("storage")] public ulong Store { get; set; }
|
||||
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
|
||||
[JsonPropertyName("reserved_storage")]public ulong ReservedStore { get; set; }
|
||||
[JsonPropertyName("streams")] public int Streams { get; set; }
|
||||
[JsonPropertyName("consumers")] public int Consumers { get; set; }
|
||||
[JsonPropertyName("limits")] public JetStreamAccountLimits Limits { get; set; } = new();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStreamAccountStats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Current statistics about an account's JetStream usage.
|
||||
/// Mirrors <c>JetStreamAccountStats</c> in server/jetstream.go.
|
||||
/// Embeds <see cref="JetStreamTier"/> for totals.
|
||||
/// </summary>
|
||||
public sealed class JetStreamAccountStats
|
||||
{
|
||||
// Embedded JetStreamTier fields (Go struct embedding)
|
||||
[JsonPropertyName("memory")] public ulong Memory { get; set; }
|
||||
[JsonPropertyName("storage")] public ulong Store { get; set; }
|
||||
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
|
||||
[JsonPropertyName("reserved_storage")]public ulong ReservedStore { get; set; }
|
||||
[JsonPropertyName("streams")] public int Streams { get; set; }
|
||||
[JsonPropertyName("consumers")] public int Consumers { get; set; }
|
||||
[JsonPropertyName("limits")] public JetStreamAccountLimits Limits { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
[JsonPropertyName("api")] public JetStreamApiStats Api { get; set; } = new();
|
||||
[JsonPropertyName("tiers")] public Dictionary<string, JetStreamTier>? Tiers { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal JetStream engine types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The main JetStream engine, one per server.
|
||||
/// Mirrors <c>jetStream</c> struct in server/jetstream.go.
|
||||
/// </summary>
|
||||
internal sealed class JetStream
|
||||
{
|
||||
// Atomic counters (use Interlocked for thread-safety)
|
||||
public long ApiInflight;
|
||||
public long ApiTotal;
|
||||
public long ApiErrors;
|
||||
public long MemReserved;
|
||||
public long StoreReserved;
|
||||
public long MemUsed;
|
||||
public long StoreUsed;
|
||||
public long QueueLimit;
|
||||
public int Clustered; // atomic int32
|
||||
|
||||
private readonly ReaderWriterLockSlim _mu = new();
|
||||
|
||||
public object? Server { get; set; } // *Server — set at runtime
|
||||
public JetStreamConfig Config { get; set; } = new();
|
||||
public object? Cluster { get; set; } // *jetStreamCluster — session 20+
|
||||
public Dictionary<string, JsAccount> Accounts { get; } = new(StringComparer.Ordinal);
|
||||
public DateTime Started { get; set; }
|
||||
|
||||
// State booleans
|
||||
public bool MetaRecovering { get; set; }
|
||||
public bool StandAlone { get; set; }
|
||||
public bool Oos { get; set; }
|
||||
public bool ShuttingDown { get; set; }
|
||||
public int Disabled; // atomic bool (0=false, 1=true)
|
||||
|
||||
public ReaderWriterLockSlim Lock => _mu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks remote per-tier usage for a JetStream account.
|
||||
/// Mirrors <c>remoteUsage</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
internal sealed class RemoteUsage
|
||||
{
|
||||
public Dictionary<string, JsaUsage> Tiers { get; } = new(StringComparer.Ordinal);
|
||||
public ulong Api;
|
||||
public ulong Err;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tier storage accounting (total + local split).
|
||||
/// Mirrors <c>jsaStorage</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
internal sealed class JsaStorage
|
||||
{
|
||||
public JsaUsage Total { get; set; } = new();
|
||||
public JsaUsage Local { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A JetStream-enabled account, holding streams, limits and usage tracking.
|
||||
/// Mirrors <c>jsAccount</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
internal sealed class JsAccount
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _mu = new();
|
||||
|
||||
public object? Js { get; set; } // *jetStream
|
||||
public object? Account { get; set; } // *Account
|
||||
public string StoreDir { get; set; } = string.Empty;
|
||||
|
||||
// Concurrent inflight map (mirrors sync.Map)
|
||||
public readonly System.Collections.Concurrent.ConcurrentDictionary<string, object?> Inflight = new();
|
||||
|
||||
// Streams keyed by stream name
|
||||
public Dictionary<string, object?> Streams { get; } = new(StringComparer.Ordinal); // *stream
|
||||
|
||||
// Send queue (mirrors *ipQueue[*pubMsg])
|
||||
public object? SendQ { get; set; }
|
||||
|
||||
// Atomic sync flag (0=false, 1=true)
|
||||
public int Sync;
|
||||
|
||||
// Usage/limits (protected by UsageMu)
|
||||
private readonly ReaderWriterLockSlim _usageMu = new();
|
||||
public Dictionary<string, JetStreamAccountLimits> Limits { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, JsaStorage> Usage { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, RemoteUsage> RUsage { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ulong ApiTotal { get; set; }
|
||||
public ulong ApiErrors { get; set; }
|
||||
public ulong UsageApi { get; set; }
|
||||
public ulong UsageErr { get; set; }
|
||||
public string UpdatesPub { get; set; } = string.Empty;
|
||||
public object? UpdatesSub { get; set; } // *subscription
|
||||
public DateTime LUpdate { get; set; }
|
||||
|
||||
public ReaderWriterLockSlim Lock => _mu;
|
||||
public ReaderWriterLockSlim UsageLock => _usageMu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Memory and store byte usage.
|
||||
/// Mirrors <c>jsaUsage</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
internal sealed class JsaUsage
|
||||
{
|
||||
public long Mem { get; set; }
|
||||
public long Store { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delegate for a function that generates a key-encryption key from a context byte array.
|
||||
/// Mirrors <c>keyGen</c> in server/jetstream.go.
|
||||
/// </summary>
|
||||
public delegate byte[] KeyGen(byte[] context);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JetStream message header helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Static helpers for extracting TTL, scheduling, and scheduler information
|
||||
/// from JetStream message headers.
|
||||
/// Mirrors <c>getMessageTTL</c>, <c>nextMessageSchedule</c>, <c>getMessageScheduler</c>
|
||||
/// in server/stream.go.
|
||||
/// </summary>
|
||||
public static class JetStreamHeaderHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the TTL value (in seconds) from the message header.
|
||||
/// Returns 0 if no TTL header is present. Returns -1 for "never".
|
||||
/// Mirrors Go <c>getMessageTTL</c>.
|
||||
/// </summary>
|
||||
public static (long Ttl, Exception? Error) GetMessageTtl(byte[] hdr)
|
||||
{
|
||||
var raw = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsMessageTtl, hdr);
|
||||
if (raw == null || raw.Length == 0)
|
||||
return (0, null);
|
||||
|
||||
return ParseMessageTtl(System.Text.Encoding.ASCII.GetString(raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a TTL string value into seconds.
|
||||
/// Supports "never" (-1), Go-style duration strings ("30s", "5m"), or plain integer seconds.
|
||||
/// Mirrors Go <c>parseMessageTTL</c>.
|
||||
/// </summary>
|
||||
public static (long Ttl, Exception? Error) ParseMessageTtl(string ttl)
|
||||
{
|
||||
if (string.Equals(ttl, "never", StringComparison.OrdinalIgnoreCase))
|
||||
return (-1, null);
|
||||
|
||||
// Try parsing as a Go-style duration.
|
||||
if (TryParseDuration(ttl, out var dur))
|
||||
{
|
||||
if (dur.TotalSeconds < 1)
|
||||
return (0, new InvalidOperationException("message TTL invalid"));
|
||||
return ((long)dur.TotalSeconds, null);
|
||||
}
|
||||
|
||||
// Try as plain integer (seconds).
|
||||
if (long.TryParse(ttl, out var t))
|
||||
{
|
||||
if (t < 0)
|
||||
return (0, new InvalidOperationException("message TTL invalid"));
|
||||
return (t, null);
|
||||
}
|
||||
|
||||
return (0, new InvalidOperationException("message TTL invalid"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the next scheduled fire time from the message header.
|
||||
/// Returns (DateTime, true) if valid, (default, true) if no header, (default, false) on parse error.
|
||||
/// Mirrors Go <c>nextMessageSchedule</c>.
|
||||
/// </summary>
|
||||
public static (DateTime Schedule, bool Ok) NextMessageSchedule(byte[] hdr, long ts)
|
||||
{
|
||||
if (hdr.Length == 0)
|
||||
return (default, true);
|
||||
|
||||
var slice = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsSchedulePattern, hdr);
|
||||
if (slice == null || slice.Value.Length == 0)
|
||||
return (default, true);
|
||||
|
||||
var val = System.Text.Encoding.ASCII.GetString(slice.Value.Span);
|
||||
var (schedule, _, ok) = Internal.MsgScheduling.ParseMsgSchedule(val, ts);
|
||||
return (schedule, ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the scheduler identifier from the message header.
|
||||
/// Returns empty string if not present.
|
||||
/// Mirrors Go <c>getMessageScheduler</c>.
|
||||
/// </summary>
|
||||
public static string GetMessageScheduler(byte[] hdr)
|
||||
{
|
||||
if (hdr.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
var raw = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsScheduler, hdr);
|
||||
if (raw == null || raw.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
return System.Text.Encoding.ASCII.GetString(raw);
|
||||
}
|
||||
|
||||
private static bool TryParseDuration(string s, out TimeSpan result)
|
||||
{
|
||||
result = default;
|
||||
if (s.EndsWith("ms", StringComparison.Ordinal) && double.TryParse(s[..^2], out var ms))
|
||||
{ result = TimeSpan.FromMilliseconds(ms); return true; }
|
||||
if (s.EndsWith('s') && double.TryParse(s[..^1], out var sec))
|
||||
{ result = TimeSpan.FromSeconds(sec); return true; }
|
||||
if (s.EndsWith('m') && double.TryParse(s[..^1], out var min))
|
||||
{ result = TimeSpan.FromMinutes(min); return true; }
|
||||
if (s.EndsWith('h') && double.TryParse(s[..^1], out var hr))
|
||||
{ result = TimeSpan.FromHours(hr); return true; }
|
||||
return TimeSpan.TryParse(s, out result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// Copyright 2024-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/jetstream_versioning.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// JetStream API level versioning constants and helpers.
|
||||
/// Mirrors server/jetstream_versioning.go.
|
||||
/// </summary>
|
||||
public static class JetStreamVersioning
|
||||
{
|
||||
/// <summary>Maximum supported JetStream API level for this server.</summary>
|
||||
public const int JsApiLevel = 3;
|
||||
|
||||
/// <summary>Metadata key that carries the required API level for a stream or consumer asset.</summary>
|
||||
public const string JsRequiredLevelMetadataKey = "_nats.req.level";
|
||||
|
||||
/// <summary>Metadata key that carries the server version that created/updated the asset.</summary>
|
||||
public const string JsServerVersionMetadataKey = "_nats.ver";
|
||||
|
||||
/// <summary>Metadata key that carries the server API level that created/updated the asset.</summary>
|
||||
public const string JsServerLevelMetadataKey = "_nats.level";
|
||||
|
||||
// ---- API level feature gates ----
|
||||
// These document which API level each feature requires.
|
||||
// They correspond to the requires() calls in setStaticStreamMetadata / setStaticConsumerMetadata.
|
||||
|
||||
/// <summary>API level required for per-message TTL and SubjectDeleteMarkerTTL (v2.11).</summary>
|
||||
public const int ApiLevelForTTL = 1;
|
||||
|
||||
/// <summary>API level required for consumer PauseUntil (v2.11).</summary>
|
||||
public const int ApiLevelForConsumerPause = 1;
|
||||
|
||||
/// <summary>API level required for priority groups (v2.11).</summary>
|
||||
public const int ApiLevelForPriorityGroups = 1;
|
||||
|
||||
/// <summary>API level required for counter CRDTs (v2.12).</summary>
|
||||
public const int ApiLevelForCounters = 2;
|
||||
|
||||
/// <summary>API level required for atomic batch publishing (v2.12).</summary>
|
||||
public const int ApiLevelForAtomicPublish = 2;
|
||||
|
||||
/// <summary>API level required for message scheduling (v2.12).</summary>
|
||||
public const int ApiLevelForMsgSchedules = 2;
|
||||
|
||||
/// <summary>API level required for async persist mode (v2.12).</summary>
|
||||
public const int ApiLevelForAsyncPersist = 2;
|
||||
|
||||
// ---- Helper methods ----
|
||||
|
||||
/// <summary>
|
||||
/// Returns the required API level string from stream or consumer metadata,
|
||||
/// or an empty string if not set.
|
||||
/// Mirrors <c>getRequiredApiLevel</c>.
|
||||
/// </summary>
|
||||
public static string GetRequiredApiLevel(IDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is not null && metadata.TryGetValue(JsRequiredLevelMetadataKey, out var l) && l.Length > 0)
|
||||
return l;
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether this server supports the required API level encoded in the asset's metadata.
|
||||
/// Mirrors <c>supportsRequiredApiLevel</c>.
|
||||
/// </summary>
|
||||
public static bool SupportsRequiredApiLevel(IDictionary<string, string>? metadata)
|
||||
{
|
||||
var l = GetRequiredApiLevel(metadata);
|
||||
if (l.Length == 0) return true;
|
||||
return int.TryParse(l, out var level) && level <= JsApiLevel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes dynamic (per-response) versioning fields from metadata.
|
||||
/// These should never be stored; only added in API responses.
|
||||
/// Mirrors <c>deleteDynamicMetadata</c>.
|
||||
/// </summary>
|
||||
public static void DeleteDynamicMetadata(IDictionary<string, string> metadata)
|
||||
{
|
||||
metadata.Remove(JsServerVersionMetadataKey);
|
||||
metadata.Remove(JsServerLevelMetadataKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a request should be rejected based on the Nats-Required-Api-Level header value.
|
||||
/// Mirrors <c>errorOnRequiredApiLevel</c>.
|
||||
/// </summary>
|
||||
public static bool ErrorOnRequiredApiLevel(string? reqApiLevelHeader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reqApiLevelHeader)) return false;
|
||||
return !int.TryParse(reqApiLevelHeader, out var minLevel) || JsApiLevel < minLevel;
|
||||
}
|
||||
|
||||
// ---- Stream metadata mutations ----
|
||||
|
||||
/// <summary>
|
||||
/// Sets the required API level in stream config metadata based on which v2.11+/v2.12+ features
|
||||
/// the stream config uses. Removes any dynamic fields first.
|
||||
/// Mirrors <c>setStaticStreamMetadata</c>.
|
||||
/// </summary>
|
||||
public static void SetStaticStreamMetadata(StreamConfig cfg)
|
||||
{
|
||||
cfg.Metadata ??= new Dictionary<string, string>();
|
||||
DeleteDynamicMetadata(cfg.Metadata);
|
||||
|
||||
var requiredApiLevel = 0;
|
||||
void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; }
|
||||
|
||||
if (cfg.AllowMsgTTL || cfg.SubjectDeleteMarkerTTL > TimeSpan.Zero)
|
||||
Requires(ApiLevelForTTL);
|
||||
if (cfg.AllowMsgCounter)
|
||||
Requires(ApiLevelForCounters);
|
||||
if (cfg.AllowAtomicPublish)
|
||||
Requires(ApiLevelForAtomicPublish);
|
||||
if (cfg.AllowMsgSchedules)
|
||||
Requires(ApiLevelForMsgSchedules);
|
||||
if (cfg.PersistMode == PersistModeType.AsyncPersistMode)
|
||||
Requires(ApiLevelForAsyncPersist);
|
||||
|
||||
cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow copy of the stream config with dynamic versioning fields added to a new
|
||||
/// metadata dictionary. Does not mutate <paramref name="cfg"/>.
|
||||
/// Mirrors <c>setDynamicStreamMetadata</c>.
|
||||
/// </summary>
|
||||
public static StreamConfig SetDynamicStreamMetadata(StreamConfig cfg)
|
||||
{
|
||||
// Shallow-copy the struct-like record: clone all fields then replace metadata.
|
||||
var newCfg = cfg.Clone();
|
||||
newCfg.Metadata = new Dictionary<string, string>();
|
||||
if (cfg.Metadata != null)
|
||||
foreach (var kv in cfg.Metadata)
|
||||
newCfg.Metadata[kv.Key] = kv.Value;
|
||||
newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version;
|
||||
newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString();
|
||||
return newCfg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the required-level versioning field from <paramref name="prevCfg"/> into
|
||||
/// <paramref name="cfg"/>, removing dynamic fields and deleting the key if absent in prevCfg.
|
||||
/// Mirrors <c>copyStreamMetadata</c>.
|
||||
/// </summary>
|
||||
public static void CopyStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg)
|
||||
{
|
||||
if (cfg.Metadata != null)
|
||||
DeleteDynamicMetadata(cfg.Metadata);
|
||||
SetOrDeleteInStreamMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey);
|
||||
}
|
||||
|
||||
private static void SetOrDeleteInStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg, string key)
|
||||
{
|
||||
if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value))
|
||||
{
|
||||
cfg.Metadata ??= new Dictionary<string, string>();
|
||||
cfg.Metadata[key] = value;
|
||||
return;
|
||||
}
|
||||
if (cfg.Metadata != null)
|
||||
{
|
||||
cfg.Metadata.Remove(key);
|
||||
if (cfg.Metadata.Count == 0)
|
||||
cfg.Metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Consumer metadata mutations ----
|
||||
|
||||
/// <summary>
|
||||
/// Sets the required API level in consumer config metadata based on which v2.11+ features
|
||||
/// the consumer config uses. Removes any dynamic fields first.
|
||||
/// Mirrors <c>setStaticConsumerMetadata</c>.
|
||||
/// </summary>
|
||||
public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
|
||||
{
|
||||
cfg.Metadata ??= new Dictionary<string, string>();
|
||||
DeleteDynamicMetadata(cfg.Metadata);
|
||||
|
||||
var requiredApiLevel = 0;
|
||||
void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; }
|
||||
|
||||
if (cfg.PauseUntil.HasValue && cfg.PauseUntil.Value != default)
|
||||
Requires(ApiLevelForConsumerPause);
|
||||
if (cfg.PriorityPolicy != PriorityPolicy.PriorityNone
|
||||
|| cfg.PinnedTTL != TimeSpan.Zero
|
||||
|| (cfg.PriorityGroups != null && cfg.PriorityGroups.Length > 0))
|
||||
Requires(ApiLevelForPriorityGroups);
|
||||
|
||||
cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow copy of the consumer config with dynamic versioning fields added to a new
|
||||
/// metadata dictionary. Does not mutate <paramref name="cfg"/>.
|
||||
/// Mirrors <c>setDynamicConsumerMetadata</c>.
|
||||
/// </summary>
|
||||
public static ConsumerConfig SetDynamicConsumerMetadata(ConsumerConfig cfg)
|
||||
{
|
||||
var newCfg = new ConsumerConfig();
|
||||
// Copy all fields via serialisation-free approach: copy properties from cfg
|
||||
CopyConsumerConfigFields(cfg, newCfg);
|
||||
newCfg.Metadata = new Dictionary<string, string>();
|
||||
if (cfg.Metadata != null)
|
||||
foreach (var kv in cfg.Metadata)
|
||||
newCfg.Metadata[kv.Key] = kv.Value;
|
||||
newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version;
|
||||
newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString();
|
||||
return newCfg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow copy of the consumer info with dynamic versioning fields added to the
|
||||
/// config's metadata. Does not mutate <paramref name="info"/>.
|
||||
/// Mirrors <c>setDynamicConsumerInfoMetadata</c>.
|
||||
/// </summary>
|
||||
public static ConsumerInfo SetDynamicConsumerInfoMetadata(ConsumerInfo info)
|
||||
{
|
||||
var newInfo = new ConsumerInfo
|
||||
{
|
||||
Stream = info.Stream,
|
||||
Name = info.Name,
|
||||
Created = info.Created,
|
||||
Delivered = info.Delivered,
|
||||
AckFloor = info.AckFloor,
|
||||
NumAckPending = info.NumAckPending,
|
||||
NumRedelivered = info.NumRedelivered,
|
||||
NumWaiting = info.NumWaiting,
|
||||
NumPending = info.NumPending,
|
||||
Cluster = info.Cluster,
|
||||
PushBound = info.PushBound,
|
||||
Paused = info.Paused,
|
||||
PauseRemaining = info.PauseRemaining,
|
||||
TimeStamp = info.TimeStamp,
|
||||
PriorityGroups = info.PriorityGroups,
|
||||
Config = info.Config != null ? SetDynamicConsumerMetadata(info.Config) : null,
|
||||
};
|
||||
return newInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the required-level versioning field from <paramref name="prevCfg"/> into
|
||||
/// <paramref name="cfg"/>, removing dynamic fields and deleting the key if absent in prevCfg.
|
||||
/// Mirrors <c>copyConsumerMetadata</c>.
|
||||
/// </summary>
|
||||
public static void CopyConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg)
|
||||
{
|
||||
if (cfg.Metadata != null)
|
||||
DeleteDynamicMetadata(cfg.Metadata);
|
||||
SetOrDeleteInConsumerMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey);
|
||||
}
|
||||
|
||||
private static void SetOrDeleteInConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg, string key)
|
||||
{
|
||||
if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value))
|
||||
{
|
||||
cfg.Metadata ??= new Dictionary<string, string>();
|
||||
cfg.Metadata[key] = value;
|
||||
return;
|
||||
}
|
||||
if (cfg.Metadata != null)
|
||||
{
|
||||
cfg.Metadata.Remove(key);
|
||||
if (cfg.Metadata.Count == 0)
|
||||
cfg.Metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Copies all scalar/reference properties from <paramref name="src"/> to <paramref name="dst"/>,
|
||||
/// excluding <c>Metadata</c> (which is set separately by the caller).
|
||||
/// </summary>
|
||||
private static void CopyConsumerConfigFields(ConsumerConfig src, ConsumerConfig dst)
|
||||
{
|
||||
dst.DeliverPolicy = src.DeliverPolicy;
|
||||
dst.OptStartSeq = src.OptStartSeq;
|
||||
dst.OptStartTime = src.OptStartTime;
|
||||
dst.DeliverSubject = src.DeliverSubject;
|
||||
dst.DeliverGroup = src.DeliverGroup;
|
||||
dst.Durable = src.Durable;
|
||||
dst.Name = src.Name;
|
||||
dst.Description = src.Description;
|
||||
dst.FilterSubject = src.FilterSubject;
|
||||
dst.FilterSubjects = src.FilterSubjects;
|
||||
dst.AckPolicy = src.AckPolicy;
|
||||
dst.AckWait = src.AckWait;
|
||||
dst.MaxDeliver = src.MaxDeliver;
|
||||
dst.BackOff = src.BackOff;
|
||||
dst.ReplayPolicy = src.ReplayPolicy;
|
||||
dst.RateLimit = src.RateLimit;
|
||||
dst.SampleFrequency = src.SampleFrequency;
|
||||
dst.MaxWaiting = src.MaxWaiting;
|
||||
dst.MaxAckPending = src.MaxAckPending;
|
||||
dst.FlowControl = src.FlowControl;
|
||||
dst.Heartbeat = src.Heartbeat;
|
||||
dst.Direct = src.Direct;
|
||||
dst.HeadersOnly = src.HeadersOnly;
|
||||
dst.MaxRequestBatch = src.MaxRequestBatch;
|
||||
dst.MaxRequestMaxBytes = src.MaxRequestMaxBytes;
|
||||
dst.MaxRequestExpires = src.MaxRequestExpires;
|
||||
dst.InactiveThreshold = src.InactiveThreshold;
|
||||
dst.Replicas = src.Replicas;
|
||||
dst.MemoryStorage = src.MemoryStorage;
|
||||
dst.PauseUntil = src.PauseUntil;
|
||||
dst.PinnedTTL = src.PinnedTTL;
|
||||
dst.PriorityPolicy = src.PriorityPolicy;
|
||||
dst.PriorityGroups = src.PriorityGroups;
|
||||
// Metadata is NOT copied here — caller sets it.
|
||||
}
|
||||
}
|
||||
2255
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MemStore.cs
Normal file
2255
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MemStore.cs
Normal file
File diff suppressed because it is too large
Load Diff
697
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MessageBlock.cs
Normal file
697
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/MessageBlock.cs
Normal file
@@ -0,0 +1,697 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/filestore.go (msgBlock struct and consumerFileStore struct)
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageBlock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single on-disk message block file together with its
|
||||
/// in-memory cache and index state.
|
||||
/// Mirrors the <c>msgBlock</c> struct in filestore.go.
|
||||
/// </summary>
|
||||
internal sealed class MessageBlock
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Identity fields — first/last use volatile-style access in Go via
|
||||
// atomic.LoadUint64 on the embedded msgId structs.
|
||||
// We replicate those as plain fields; callers must acquire _mu before
|
||||
// reading/writing unless using the Interlocked helpers on Seq/Ts.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>First message in this block (sequence + nanosecond timestamp).</summary>
|
||||
public MsgId First;
|
||||
|
||||
/// <summary>Last message in this block (sequence + nanosecond timestamp).</summary>
|
||||
public MsgId Last;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lock
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Guards all mutable fields in this block.</summary>
|
||||
public readonly ReaderWriterLockSlim Mu = new(LockRecursionPolicy.NoRecursion);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Back-reference
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Owning file store.</summary>
|
||||
public JetStreamFileStore? Fs { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// File I/O
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Path to the <c>.blk</c> message data file.</summary>
|
||||
public string Mfn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Open file stream for the block data file (null when closed/idle).</summary>
|
||||
public FileStream? Mfd { get; set; }
|
||||
|
||||
/// <summary>Path to the per-block encryption key file.</summary>
|
||||
public string Kfn { get; set; } = string.Empty;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Compression
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Effective compression algorithm when the block was last loaded.</summary>
|
||||
public StoreCompression Cmp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last index write size in bytes; used to detect whether re-compressing
|
||||
/// the block would save meaningful space.
|
||||
/// </summary>
|
||||
public long Liwsz { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Block identity
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Monotonically increasing block index number (used in file names).</summary>
|
||||
public uint Index { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Counters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>User-visible byte count (excludes deleted-message bytes).</summary>
|
||||
public ulong Bytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total raw byte count including deleted messages.
|
||||
/// Used to decide when to roll to a new block.
|
||||
/// </summary>
|
||||
public ulong RBytes { get; set; }
|
||||
|
||||
/// <summary>Byte count captured at the last compaction (0 if never compacted).</summary>
|
||||
public ulong CBytes { get; set; }
|
||||
|
||||
/// <summary>User-visible message count (excludes deleted messages).</summary>
|
||||
public ulong Msgs { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-subject state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-subject state tree for this block.
|
||||
/// Lazily populated and expired when idle.
|
||||
/// Mirrors <c>mb.fss</c> in filestore.go.
|
||||
/// </summary>
|
||||
public SubjectTree<SimpleState>? Fss { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Deleted-sequence tracking
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Set of deleted sequence numbers within this block.
|
||||
/// Uses the AVL-backed <see cref="SequenceSet"/> to match Go's <c>avl.SequenceSet</c>.
|
||||
/// </summary>
|
||||
public SequenceSet Dmap { get; set; } = new();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Timestamps (nanosecond Unix times, matches Go int64)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Nanosecond timestamp of the last write to this block.</summary>
|
||||
public long Lwts { get; set; }
|
||||
|
||||
/// <summary>Nanosecond timestamp of the last load (cache fill) of this block.</summary>
|
||||
public long Llts { get; set; }
|
||||
|
||||
/// <summary>Nanosecond timestamp of the last read from this block.</summary>
|
||||
public long Lrts { get; set; }
|
||||
|
||||
/// <summary>Nanosecond timestamp of the last subject-state (fss) access.</summary>
|
||||
public long Lsts { get; set; }
|
||||
|
||||
/// <summary>Last sequence that was looked up; used to detect linear scans.</summary>
|
||||
public ulong Llseq { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cache
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Active in-memory cache. May be null when evicted.
|
||||
/// Mirrors <c>mb.cache</c>; the elastic-pointer field (<c>mb.ecache</c>) is
|
||||
/// not ported — a plain nullable field is sufficient here.
|
||||
/// </summary>
|
||||
public Cache? CacheData { get; set; }
|
||||
|
||||
/// <summary>Number of times the cache has been (re)loaded from disk.</summary>
|
||||
public ulong Cloads { get; set; }
|
||||
|
||||
/// <summary>Cache buffer expiration duration for this block.</summary>
|
||||
public TimeSpan Cexp { get; set; }
|
||||
|
||||
/// <summary>Per-block subject-state (fss) expiration duration.</summary>
|
||||
public TimeSpan Fexp { get; set; }
|
||||
|
||||
/// <summary>Timer used to expire the cache buffer when idle.</summary>
|
||||
public Timer? Ctmr { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// State flags
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Whether the in-memory cache is currently populated.</summary>
|
||||
public bool HaveCache => CacheData != null;
|
||||
|
||||
/// <summary>Whether this block has been closed and must not be written to.</summary>
|
||||
public bool Closed { get; set; }
|
||||
|
||||
/// <summary>Whether this block has unflushed data that must be synced to disk.</summary>
|
||||
public bool NeedSync { get; set; }
|
||||
|
||||
/// <summary>When true every write is immediately synced (SyncAlways mode).</summary>
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
/// <summary>When true compaction is suppressed for this block.</summary>
|
||||
public bool NoCompact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true the block's messages are not tracked in the per-subject index.
|
||||
/// Used for blocks that only contain tombstone or deleted markers.
|
||||
/// </summary>
|
||||
public bool NoTrack { get; set; }
|
||||
|
||||
/// <summary>Whether a background flusher goroutine equivalent is running.</summary>
|
||||
public bool Flusher { get; set; }
|
||||
|
||||
/// <summary>Whether a cache-load is currently in progress.</summary>
|
||||
public bool Loading { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Write error
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Captured write error; non-null means the block is in a bad state.</summary>
|
||||
public Exception? Werr { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TTL / scheduling counters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Number of messages in this block that have a TTL set.</summary>
|
||||
public ulong Ttls { get; set; }
|
||||
|
||||
/// <summary>Number of messages in this block that have a schedule set.</summary>
|
||||
public ulong Schedules { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Channels (equivalent to Go chan struct{})
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Flush-request channel: signals the flusher to run.</summary>
|
||||
public Channel<byte>? Fch { get; set; }
|
||||
|
||||
/// <summary>Quit channel: closing signals background goroutine equivalents to stop.</summary>
|
||||
public Channel<byte>? Qch { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Checksum
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Last-check hash: 8-byte rolling checksum of the last validated record.
|
||||
/// Mirrors <c>mb.lchk [8]byte</c>.
|
||||
/// </summary>
|
||||
public byte[] Lchk { get; set; } = new byte[8];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Test hook
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>When true, simulates a write failure. Used by unit tests only.</summary>
|
||||
public bool MockWriteErr { get; set; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConsumerFileStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// File-backed implementation of <see cref="IConsumerStore"/>.
|
||||
/// Persists consumer delivery and ack state to a directory under the stream's
|
||||
/// <c>obs/</c> subdirectory.
|
||||
/// Mirrors the <c>consumerFileStore</c> struct in filestore.go.
|
||||
/// </summary>
|
||||
public sealed class ConsumerFileStore : IConsumerStore
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Fields — mirrors consumerFileStore struct
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private readonly object _mu = new();
|
||||
|
||||
/// <summary>Back-reference to the owning file store.</summary>
|
||||
private readonly JetStreamFileStore _fs;
|
||||
|
||||
/// <summary>Consumer metadata (name, created time, config).</summary>
|
||||
private FileConsumerInfo _cfg;
|
||||
|
||||
/// <summary>Durable consumer name.</summary>
|
||||
private readonly string _name;
|
||||
|
||||
/// <summary>Path to the consumer's state directory.</summary>
|
||||
private readonly string _odir;
|
||||
|
||||
/// <summary>Path to the consumer state file (<c>o.dat</c>).</summary>
|
||||
private readonly string _ifn;
|
||||
|
||||
/// <summary>Consumer delivery/ack state.</summary>
|
||||
private ConsumerState _state = new();
|
||||
|
||||
/// <summary>Flush-request channel.</summary>
|
||||
private Channel<byte>? _fch;
|
||||
|
||||
/// <summary>Quit channel.</summary>
|
||||
private Channel<byte>? _qch;
|
||||
|
||||
/// <summary>Whether a background flusher is running.</summary>
|
||||
private bool _flusher;
|
||||
|
||||
/// <summary>Whether a write is currently in progress.</summary>
|
||||
private bool _writing;
|
||||
|
||||
/// <summary>Whether the state is dirty (pending flush).</summary>
|
||||
private bool _dirty;
|
||||
|
||||
/// <summary>Whether this consumer store is closed.</summary>
|
||||
private bool _closed;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Constructor
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new file-backed consumer store.
|
||||
/// </summary>
|
||||
public ConsumerFileStore(JetStreamFileStore fs, FileConsumerInfo cfg, string name, string odir)
|
||||
{
|
||||
_fs = fs;
|
||||
_cfg = cfg;
|
||||
_name = name;
|
||||
_odir = odir;
|
||||
_ifn = Path.Combine(odir, FileStoreDefaults.ConsumerState);
|
||||
lock (_mu)
|
||||
{
|
||||
TryLoadStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IConsumerStore
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetStarting(ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_state.Delivered.Stream = sseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateStarting(ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (sseq <= _state.Delivered.Stream)
|
||||
return;
|
||||
|
||||
_state.Delivered.Stream = sseq;
|
||||
if (_cfg.Config.AckPolicy == AckPolicy.AckNone)
|
||||
_state.AckFloor.Stream = sseq;
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Reset(ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_state = new ConsumerState();
|
||||
_state.Delivered.Stream = sseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool HasState()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
return _state.Delivered.Consumer != 0 ||
|
||||
_state.Delivered.Stream != 0 ||
|
||||
_state.Pending is { Count: > 0 } ||
|
||||
_state.Redelivered is { Count: > 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateDelivered(ulong dseq, ulong sseq, ulong dc, long ts)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
throw StoreErrors.ErrStoreClosed;
|
||||
|
||||
if (dc != 1 && _cfg.Config.AckPolicy == AckPolicy.AckNone)
|
||||
throw StoreErrors.ErrNoAckPolicy;
|
||||
|
||||
if (dseq <= _state.AckFloor.Consumer)
|
||||
return;
|
||||
|
||||
if (_cfg.Config.AckPolicy != AckPolicy.AckNone)
|
||||
{
|
||||
_state.Pending ??= new Dictionary<ulong, Pending>();
|
||||
|
||||
if (sseq <= _state.Delivered.Stream)
|
||||
{
|
||||
if (_state.Pending.TryGetValue(sseq, out var pending) && pending != null)
|
||||
pending.Timestamp = ts;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.Pending[sseq] = new Pending { Sequence = dseq, Timestamp = ts };
|
||||
}
|
||||
|
||||
if (dseq > _state.Delivered.Consumer)
|
||||
_state.Delivered.Consumer = dseq;
|
||||
if (sseq > _state.Delivered.Stream)
|
||||
_state.Delivered.Stream = sseq;
|
||||
|
||||
if (dc > 1)
|
||||
{
|
||||
var maxdc = (ulong)_cfg.Config.MaxDeliver;
|
||||
if (maxdc > 0 && dc > maxdc)
|
||||
_state.Pending.Remove(sseq);
|
||||
|
||||
_state.Redelivered ??= new Dictionary<ulong, ulong>();
|
||||
if (!_state.Redelivered.TryGetValue(sseq, out var cur) || cur < dc - 1)
|
||||
_state.Redelivered[sseq] = dc - 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dseq > _state.Delivered.Consumer)
|
||||
{
|
||||
_state.Delivered.Consumer = dseq;
|
||||
_state.AckFloor.Consumer = dseq;
|
||||
}
|
||||
if (sseq > _state.Delivered.Stream)
|
||||
{
|
||||
_state.Delivered.Stream = sseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
}
|
||||
}
|
||||
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateAcks(ulong dseq, ulong sseq)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
throw StoreErrors.ErrStoreClosed;
|
||||
|
||||
if (_cfg.Config.AckPolicy == AckPolicy.AckNone)
|
||||
throw StoreErrors.ErrNoAckPolicy;
|
||||
|
||||
if (dseq <= _state.AckFloor.Consumer)
|
||||
return;
|
||||
|
||||
if (_state.Pending == null || !_state.Pending.ContainsKey(sseq))
|
||||
{
|
||||
_state.Redelivered?.Remove(sseq);
|
||||
throw StoreErrors.ErrStoreMsgNotFound;
|
||||
}
|
||||
|
||||
if (_cfg.Config.AckPolicy == AckPolicy.AckAll)
|
||||
{
|
||||
var sgap = sseq - _state.AckFloor.Stream;
|
||||
_state.AckFloor.Consumer = dseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
|
||||
if (sgap > (ulong)_state.Pending.Count)
|
||||
{
|
||||
var toRemove = new List<ulong>();
|
||||
foreach (var kv in _state.Pending)
|
||||
if (kv.Key <= sseq)
|
||||
toRemove.Add(kv.Key);
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_state.Pending.Remove(key);
|
||||
_state.Redelivered?.Remove(key);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var seq = sseq; seq > sseq - sgap && _state.Pending.Count > 0; seq--)
|
||||
{
|
||||
_state.Pending.Remove(seq);
|
||||
_state.Redelivered?.Remove(seq);
|
||||
if (seq == 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
PersistStateLocked();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_state.Pending.TryGetValue(sseq, out var pending) && pending != null)
|
||||
{
|
||||
_state.Pending.Remove(sseq);
|
||||
if (dseq > pending.Sequence && pending.Sequence > 0)
|
||||
dseq = pending.Sequence;
|
||||
}
|
||||
|
||||
if (_state.Pending.Count == 0)
|
||||
{
|
||||
_state.AckFloor.Consumer = _state.Delivered.Consumer;
|
||||
_state.AckFloor.Stream = _state.Delivered.Stream;
|
||||
}
|
||||
else if (dseq == _state.AckFloor.Consumer + 1)
|
||||
{
|
||||
_state.AckFloor.Consumer = dseq;
|
||||
_state.AckFloor.Stream = sseq;
|
||||
|
||||
if (_state.Delivered.Consumer > dseq)
|
||||
{
|
||||
for (var ss = sseq + 1; ss <= _state.Delivered.Stream; ss++)
|
||||
{
|
||||
if (_state.Pending.TryGetValue(ss, out var p) && p != null)
|
||||
{
|
||||
if (p.Sequence > 0)
|
||||
{
|
||||
_state.AckFloor.Consumer = p.Sequence - 1;
|
||||
_state.AckFloor.Stream = ss - 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state.Redelivered?.Remove(sseq);
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void UpdateConfig(ConsumerConfig cfg)
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
_cfg.Config = cfg;
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Update(ConsumerState state)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
if (state.AckFloor.Consumer > state.Delivered.Consumer)
|
||||
throw new InvalidOperationException("bad ack floor for consumer");
|
||||
if (state.AckFloor.Stream > state.Delivered.Stream)
|
||||
throw new InvalidOperationException("bad ack floor for stream");
|
||||
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
throw StoreErrors.ErrStoreClosed;
|
||||
|
||||
if (state.Delivered.Consumer < _state.Delivered.Consumer ||
|
||||
state.AckFloor.Stream < _state.AckFloor.Stream)
|
||||
throw new InvalidOperationException("old update ignored");
|
||||
|
||||
_state = CloneState(state, copyCollections: true);
|
||||
PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ConsumerState? State, Exception? Error) State()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
return (null, StoreErrors.ErrStoreClosed);
|
||||
return (CloneState(_state, copyCollections: true), null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public (ConsumerState? State, Exception? Error) BorrowState()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
return (null, StoreErrors.ErrStoreClosed);
|
||||
return (CloneState(_state, copyCollections: false), null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public byte[] EncodedState()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
throw StoreErrors.ErrStoreClosed;
|
||||
return JsonSerializer.SerializeToUtf8Bytes(CloneState(_state, copyCollections: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public StorageType Type() => StorageType.FileStorage;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Stop()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
if (_closed)
|
||||
return;
|
||||
PersistStateLocked();
|
||||
_closed = true;
|
||||
}
|
||||
_fs.RemoveConsumer(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete()
|
||||
{
|
||||
Stop();
|
||||
if (Directory.Exists(_odir))
|
||||
Directory.Delete(_odir, recursive: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StreamDelete()
|
||||
=> Stop();
|
||||
|
||||
private void TryLoadStateLocked()
|
||||
{
|
||||
if (!File.Exists(_ifn))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var raw = File.ReadAllBytes(_ifn);
|
||||
var loaded = JsonSerializer.Deserialize<ConsumerState>(raw);
|
||||
if (loaded != null)
|
||||
_state = CloneState(loaded, copyCollections: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_state = new ConsumerState();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistStateLocked()
|
||||
{
|
||||
if (_closed)
|
||||
return;
|
||||
|
||||
Directory.CreateDirectory(_odir);
|
||||
var encoded = JsonSerializer.SerializeToUtf8Bytes(CloneState(_state, copyCollections: true));
|
||||
File.WriteAllBytes(_ifn, encoded);
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
private static ConsumerState CloneState(ConsumerState state, bool copyCollections)
|
||||
{
|
||||
var clone = new ConsumerState
|
||||
{
|
||||
Delivered = new SequencePair
|
||||
{
|
||||
Consumer = state.Delivered.Consumer,
|
||||
Stream = state.Delivered.Stream,
|
||||
},
|
||||
AckFloor = new SequencePair
|
||||
{
|
||||
Consumer = state.AckFloor.Consumer,
|
||||
Stream = state.AckFloor.Stream,
|
||||
},
|
||||
};
|
||||
|
||||
if (state.Pending is { Count: > 0 })
|
||||
{
|
||||
clone.Pending = new Dictionary<ulong, Pending>(state.Pending.Count);
|
||||
foreach (var kv in state.Pending)
|
||||
{
|
||||
clone.Pending[kv.Key] = new Pending
|
||||
{
|
||||
Sequence = kv.Value.Sequence,
|
||||
Timestamp = kv.Value.Timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (!copyCollections)
|
||||
{
|
||||
clone.Pending = state.Pending;
|
||||
}
|
||||
|
||||
if (state.Redelivered is { Count: > 0 })
|
||||
clone.Redelivered = new Dictionary<ulong, ulong>(state.Redelivered);
|
||||
else if (!copyCollections)
|
||||
clone.Redelivered = state.Redelivered;
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
246
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs
Normal file
246
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsConsumer.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/consumer.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JetStream consumer, managing message delivery, ack tracking, and lifecycle.
|
||||
/// Mirrors the <c>consumer</c> struct in server/consumer.go.
|
||||
/// </summary>
|
||||
internal sealed class NatsConsumer : IDisposable
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion);
|
||||
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public string Stream { get; private set; } = string.Empty;
|
||||
public ConsumerConfig Config { get; private set; } = new();
|
||||
public DateTime Created { get; private set; }
|
||||
|
||||
// Atomic counters — use Interlocked for thread-safe access
|
||||
internal long Delivered;
|
||||
internal long AckFloor;
|
||||
internal long NumAckPending;
|
||||
internal long NumRedelivered;
|
||||
|
||||
private bool _closed;
|
||||
private bool _isLeader;
|
||||
private ulong _leaderTerm;
|
||||
private ConsumerState _state = new();
|
||||
|
||||
/// <summary>IRaftNode — stored as object to avoid cross-dependency on Raft session.</summary>
|
||||
private object? _node;
|
||||
|
||||
private CancellationTokenSource? _quitCts;
|
||||
|
||||
public NatsConsumer(string stream, ConsumerConfig config, DateTime created)
|
||||
{
|
||||
Stream = stream;
|
||||
Name = (config.Name is { Length: > 0 } name) ? name
|
||||
: (config.Durable ?? string.Empty);
|
||||
Config = config;
|
||||
Created = created;
|
||||
_quitCts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="NatsConsumer"/> for the given stream.
|
||||
/// Returns null if the consumer cannot be created (stub: always throws).
|
||||
/// Mirrors <c>newConsumer</c> / <c>consumer.create</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public static NatsConsumer? Create(
|
||||
NatsStream stream,
|
||||
ConsumerConfig cfg,
|
||||
ConsumerAction action,
|
||||
ConsumerAssignment? sa)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentNullException.ThrowIfNull(cfg);
|
||||
return new NatsConsumer(stream.Name, cfg, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Stops processing and tears down goroutines / timers.
|
||||
/// Mirrors <c>consumer.stop</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_closed)
|
||||
return;
|
||||
_closed = true;
|
||||
_isLeader = false;
|
||||
_quitCts?.Cancel();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the consumer and all associated state permanently.
|
||||
/// Mirrors <c>consumer.delete</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public void Delete() => Stop();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Info / State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of consumer info including config and delivery state.
|
||||
/// Mirrors <c>consumer.info</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public ConsumerInfo GetInfo()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return new ConsumerInfo
|
||||
{
|
||||
Stream = Stream,
|
||||
Name = Name,
|
||||
Created = Created,
|
||||
Config = Config,
|
||||
Delivered = new SequenceInfo
|
||||
{
|
||||
Consumer = _state.Delivered.Consumer,
|
||||
Stream = _state.Delivered.Stream,
|
||||
},
|
||||
AckFloor = new SequenceInfo
|
||||
{
|
||||
Consumer = _state.AckFloor.Consumer,
|
||||
Stream = _state.AckFloor.Stream,
|
||||
},
|
||||
NumAckPending = (int)NumAckPending,
|
||||
NumRedelivered = (int)NumRedelivered,
|
||||
TimeStamp = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current consumer configuration.
|
||||
/// Mirrors <c>consumer.config</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public ConsumerConfig GetConfig()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return Config; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an updated configuration to the consumer.
|
||||
/// Mirrors <c>consumer.update</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public void UpdateConfig(ConsumerConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_mu.EnterWriteLock();
|
||||
try { Config = config; }
|
||||
finally { _mu.ExitWriteLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current durable consumer state (delivered, ack_floor, pending, redelivered).
|
||||
/// Mirrors <c>consumer.state</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public ConsumerState GetConsumerState()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return new ConsumerState
|
||||
{
|
||||
Delivered = new SequencePair
|
||||
{
|
||||
Consumer = _state.Delivered.Consumer,
|
||||
Stream = _state.Delivered.Stream,
|
||||
},
|
||||
AckFloor = new SequencePair
|
||||
{
|
||||
Consumer = _state.AckFloor.Consumer,
|
||||
Stream = _state.AckFloor.Stream,
|
||||
},
|
||||
Pending = _state.Pending is { Count: > 0 } ? new Dictionary<ulong, Pending>(_state.Pending) : null,
|
||||
Redelivered = _state.Redelivered is { Count: > 0 } ? new Dictionary<ulong, ulong>(_state.Redelivered) : null,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Leadership
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this server is the current consumer leader.
|
||||
/// Mirrors <c>consumer.isLeader</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public bool IsLeader()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _isLeader && !_closed; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transitions this consumer into or out of the leader role.
|
||||
/// Mirrors <c>consumer.setLeader</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public void SetLeader(bool isLeader, ulong term)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_isLeader = isLeader;
|
||||
_leaderTerm = term;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_quitCts?.Cancel();
|
||||
_quitCts?.Dispose();
|
||||
_quitCts = null;
|
||||
_mu.Dispose();
|
||||
}
|
||||
}
|
||||
357
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs
Normal file
357
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/NatsStream.cs
Normal file
@@ -0,0 +1,357 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/stream.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JetStream stream, managing message storage, replication, and lifecycle.
|
||||
/// Mirrors the <c>stream</c> struct in server/stream.go.
|
||||
/// </summary>
|
||||
internal sealed class NatsStream : IDisposable
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion);
|
||||
|
||||
public Account Account { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public StreamConfig Config { get; private set; } = new();
|
||||
public DateTime Created { get; private set; }
|
||||
internal IStreamStore? Store { get; private set; }
|
||||
|
||||
// Atomic counters — use Interlocked for thread-safe access
|
||||
internal long Msgs;
|
||||
internal long Bytes;
|
||||
internal long FirstSeq;
|
||||
internal long LastSeq;
|
||||
|
||||
internal bool IsMirror;
|
||||
|
||||
private bool _closed;
|
||||
private bool _isLeader;
|
||||
private ulong _leaderTerm;
|
||||
private bool _sealed;
|
||||
private CancellationTokenSource? _quitCts;
|
||||
|
||||
/// <summary>IRaftNode — stored as object to avoid cross-dependency on Raft session.</summary>
|
||||
private object? _node;
|
||||
|
||||
public NatsStream(Account account, StreamConfig config, DateTime created)
|
||||
{
|
||||
Account = account;
|
||||
Name = config.Name ?? string.Empty;
|
||||
Config = config;
|
||||
Created = created;
|
||||
_quitCts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="NatsStream"/> after validating the configuration.
|
||||
/// Returns null if the stream cannot be created (stub: always throws).
|
||||
/// Mirrors <c>newStream</c> / <c>stream.create</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public static NatsStream? Create(
|
||||
Account acc,
|
||||
StreamConfig cfg,
|
||||
object? jsacc,
|
||||
IStreamStore? store,
|
||||
StreamAssignment? sa,
|
||||
object? server)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acc);
|
||||
ArgumentNullException.ThrowIfNull(cfg);
|
||||
|
||||
var stream = new NatsStream(acc, cfg.Clone(), DateTime.UtcNow)
|
||||
{
|
||||
Store = store,
|
||||
IsMirror = cfg.Mirror != null,
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Stops processing and tears down goroutines / timers.
|
||||
/// Mirrors <c>stream.stop</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_closed)
|
||||
return;
|
||||
|
||||
_closed = true;
|
||||
_isLeader = false;
|
||||
_quitCts?.Cancel();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the stream and all stored messages permanently.
|
||||
/// Mirrors <c>stream.delete</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_closed)
|
||||
return;
|
||||
|
||||
_closed = true;
|
||||
_isLeader = false;
|
||||
_quitCts?.Cancel();
|
||||
Store?.Delete(inline: true);
|
||||
Store = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purges messages from the stream according to the optional request filter.
|
||||
/// Mirrors <c>stream.purge</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public void Purge(StreamPurgeRequest? req = null)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_closed || Store == null)
|
||||
return;
|
||||
|
||||
if (req == null || (string.IsNullOrEmpty(req.Filter) && req.Sequence == 0 && req.Keep == 0))
|
||||
Store.Purge();
|
||||
else
|
||||
Store.PurgeEx(req.Filter ?? string.Empty, req.Sequence, req.Keep);
|
||||
|
||||
SyncCountersFromState(Store.State());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Info / State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of stream info including config, state, and cluster information.
|
||||
/// Mirrors <c>stream.info</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public StreamInfo GetInfo(bool includeDeleted = false)
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return new StreamInfo
|
||||
{
|
||||
Config = Config.Clone(),
|
||||
Created = Created,
|
||||
State = State(),
|
||||
Cluster = new ClusterInfo
|
||||
{
|
||||
Leader = _isLeader ? Name : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously returns a snapshot of stream info.
|
||||
/// Mirrors <c>stream.info</c> (async path) in server/stream.go.
|
||||
/// </summary>
|
||||
public Task<StreamInfo> GetInfoAsync(bool includeDeleted = false, CancellationToken ct = default) =>
|
||||
ct.IsCancellationRequested
|
||||
? Task.FromCanceled<StreamInfo>(ct)
|
||||
: Task.FromResult(GetInfo(includeDeleted));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current stream state (message counts, byte totals, sequences).
|
||||
/// Mirrors <c>stream.state</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public StreamState State()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (Store != null)
|
||||
return Store.State();
|
||||
|
||||
return new StreamState
|
||||
{
|
||||
Msgs = (ulong)Math.Max(0, Interlocked.Read(ref Msgs)),
|
||||
Bytes = (ulong)Math.Max(0, Interlocked.Read(ref Bytes)),
|
||||
FirstSeq = (ulong)Math.Max(0, Interlocked.Read(ref FirstSeq)),
|
||||
LastSeq = (ulong)Math.Max(0, Interlocked.Read(ref LastSeq)),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Leadership
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Transitions this stream into or out of the leader role.
|
||||
/// Mirrors <c>stream.setLeader</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public void SetLeader(bool isLeader, ulong term)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_isLeader = isLeader;
|
||||
_leaderTerm = term;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this server is the current stream leader.
|
||||
/// Mirrors <c>stream.isLeader</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public bool IsLeader()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _isLeader && !_closed; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the owning account.
|
||||
/// Mirrors <c>stream.account</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public Account GetAccount()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return Account; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current stream configuration.
|
||||
/// Mirrors <c>stream.config</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public StreamConfig GetConfig()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return Config.Clone(); }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an updated configuration to the stream.
|
||||
/// Mirrors <c>stream.update</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public void UpdateConfig(StreamConfig config)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
Config = config.Clone();
|
||||
Store?.UpdateConfig(Config);
|
||||
_sealed = Config.Sealed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sealed state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the stream is sealed (no new messages accepted).
|
||||
/// Mirrors <c>stream.isSealed</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public bool IsSealed()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _sealed || Config.Sealed; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seals the stream so that no new messages can be stored.
|
||||
/// Mirrors <c>stream.seal</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public void Seal()
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_sealed = true;
|
||||
Config.Sealed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncCountersFromState(StreamState state)
|
||||
{
|
||||
Interlocked.Exchange(ref Msgs, (long)state.Msgs);
|
||||
Interlocked.Exchange(ref Bytes, (long)state.Bytes);
|
||||
Interlocked.Exchange(ref FirstSeq, (long)state.FirstSeq);
|
||||
Interlocked.Exchange(ref LastSeq, (long)state.LastSeq);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_quitCts?.Cancel();
|
||||
_quitCts?.Dispose();
|
||||
_quitCts = null;
|
||||
_mu.Dispose();
|
||||
}
|
||||
}
|
||||
1085
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/RaftTypes.cs
Normal file
1085
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/RaftTypes.cs
Normal file
File diff suppressed because it is too large
Load Diff
1000
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.cs
Normal file
1000
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StoreTypes.cs
Normal file
File diff suppressed because it is too large
Load Diff
511
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs
Normal file
511
dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamTypes.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
// Copyright 2019-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/stream.go and server/consumer.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Stream API types (from stream.go)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A stream create request that extends <see cref="StreamConfig"/> with a pedantic flag.
|
||||
/// Mirrors <c>streamConfigRequest</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamConfigRequest
|
||||
{
|
||||
[JsonPropertyName("config")]
|
||||
public StreamConfig Config { get; set; } = new();
|
||||
|
||||
/// <summary>If true, strict validation is applied during stream creation/update.</summary>
|
||||
[JsonPropertyName("pedantic")]
|
||||
public bool Pedantic { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a stream, returned from info requests.
|
||||
/// Mirrors <c>StreamInfo</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamInfo
|
||||
{
|
||||
[JsonPropertyName("config")]
|
||||
public StreamConfig Config { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("created")]
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public StreamState State { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("mirror")]
|
||||
public StreamSourceInfo? Mirror { get; set; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public StreamSourceInfo[]? Sources { get; set; }
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
public ClusterInfo? Cluster { get; set; }
|
||||
|
||||
[JsonPropertyName("mirror_direct")]
|
||||
public bool Mirror_Direct { get; set; }
|
||||
|
||||
[JsonPropertyName("allow_direct")]
|
||||
public bool Allow_Direct { get; set; }
|
||||
|
||||
/// <summary>Alternate cluster name.</summary>
|
||||
[JsonPropertyName("alternates")]
|
||||
public string? Alt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a stream mirror or source.
|
||||
/// Mirrors <c>StreamSourceInfo</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamSourceInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("filter_subject")]
|
||||
public string? FilterSubject { get; set; }
|
||||
|
||||
[JsonPropertyName("lag")]
|
||||
public ulong Lag { get; set; }
|
||||
|
||||
[JsonPropertyName("active")]
|
||||
public DateTime? Active { get; set; }
|
||||
|
||||
[JsonPropertyName("external")]
|
||||
public StreamSource? External { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for stream info, allowing filtering.
|
||||
/// Mirrors <c>streamInfoRequest</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamInfoRequest
|
||||
{
|
||||
[JsonPropertyName("subjects_filter")]
|
||||
public string? SubjectsFilter { get; set; }
|
||||
|
||||
[JsonPropertyName("mirror_check_until")]
|
||||
public string? MirrorCheckUntil { get; set; }
|
||||
|
||||
[JsonPropertyName("deleted_details")]
|
||||
public bool DeletedDetails { get; set; }
|
||||
|
||||
[JsonPropertyName("subjects_detail")]
|
||||
public bool SubjectsDetail { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for purging a stream.
|
||||
/// Mirrors <c>StreamPurgeRequest</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamPurgeRequest
|
||||
{
|
||||
[JsonPropertyName("filter")]
|
||||
public string? Filter { get; set; }
|
||||
|
||||
[JsonPropertyName("seq")]
|
||||
public ulong Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("keep")]
|
||||
public ulong Keep { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for deleting a specific stream message.
|
||||
/// Mirrors <c>StreamMsgDeleteRequest</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamMsgDeleteRequest
|
||||
{
|
||||
[JsonPropertyName("seq")]
|
||||
public ulong Seq { get; set; }
|
||||
|
||||
[JsonPropertyName("no_erase")]
|
||||
public bool NoErase { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for retrieving a specific stream message.
|
||||
/// Mirrors <c>StreamGetMsgRequest</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class StreamGetMsgRequest
|
||||
{
|
||||
[JsonPropertyName("seq")]
|
||||
public ulong Seq { get; set; }
|
||||
|
||||
[JsonPropertyName("last_by_subj")]
|
||||
public string? LastBySubject { get; set; }
|
||||
|
||||
[JsonPropertyName("next_by_subj")]
|
||||
public string? NextBySubject { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish acknowledgement response from JetStream.
|
||||
/// Mirrors <c>JSPubAckResponse</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class JSPubAckResponse
|
||||
{
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("seq")]
|
||||
public ulong Seq { get; set; }
|
||||
|
||||
[JsonPropertyName("duplicate")]
|
||||
public bool Duplicate { get; set; }
|
||||
|
||||
[JsonPropertyName("domain")]
|
||||
public string? Domain { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public JsApiError? PubAckError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns an exception if the response contains an error, otherwise null.
|
||||
/// Mirrors <c>ToError()</c> helper pattern in NATS Go server.
|
||||
/// </summary>
|
||||
public Exception? ToError()
|
||||
{
|
||||
if (PubAckError is { ErrCode: > 0 })
|
||||
return new InvalidOperationException($"{PubAckError.Description} (errCode={PubAckError.ErrCode})");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A raw published message before JetStream processing.
|
||||
/// Mirrors <c>pubMsg</c> (JetStream variant) in server/stream.go.
|
||||
/// Note: renamed <c>JsStreamPubMsg</c> to avoid collision with the server-level
|
||||
/// <c>PubMsg</c> (events.go) which lives in the same namespace.
|
||||
/// </summary>
|
||||
public sealed class JsStreamPubMsg
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string? Reply { get; set; }
|
||||
public byte[]? Hdr { get; set; }
|
||||
public byte[]? Msg { get; set; }
|
||||
public Dictionary<string, string>? Meta { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A JetStream publish message with sync tracking.
|
||||
/// Mirrors <c>jsPubMsg</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class JsPubMsg
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string? Reply { get; set; }
|
||||
public byte[]? Hdr { get; set; }
|
||||
public byte[]? Msg { get; set; }
|
||||
|
||||
/// <summary>Publish argument (opaque, set at runtime).</summary>
|
||||
public object? Pa { get; set; }
|
||||
|
||||
/// <summary>Sync/ack channel (opaque, set at runtime).</summary>
|
||||
public object? Sync { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An inbound message to be processed by the JetStream layer.
|
||||
/// Mirrors <c>inMsg</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class InMsg
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string? Reply { get; set; }
|
||||
public byte[]? Hdr { get; set; }
|
||||
public byte[]? Msg { get; set; }
|
||||
|
||||
/// <summary>The originating client (opaque, set at runtime).</summary>
|
||||
public object? Client { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cached/clustered message for replication.
|
||||
/// Mirrors <c>cMsg</c> in server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class CMsg
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public byte[]? Msg { get; set; }
|
||||
public ulong Seq { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Consumer API types (from consumer.go)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Information about a consumer, returned from info requests.
|
||||
/// Mirrors <c>ConsumerInfo</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class ConsumerInfo
|
||||
{
|
||||
[JsonPropertyName("stream_name")]
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("created")]
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public ConsumerConfig? Config { get; set; }
|
||||
|
||||
[JsonPropertyName("delivered")]
|
||||
public SequenceInfo Delivered { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ack_floor")]
|
||||
public SequenceInfo AckFloor { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("num_ack_pending")]
|
||||
public int NumAckPending { get; set; }
|
||||
|
||||
[JsonPropertyName("num_redelivered")]
|
||||
public int NumRedelivered { get; set; }
|
||||
|
||||
[JsonPropertyName("num_waiting")]
|
||||
public int NumWaiting { get; set; }
|
||||
|
||||
[JsonPropertyName("num_pending")]
|
||||
public ulong NumPending { get; set; }
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
public ClusterInfo? Cluster { get; set; }
|
||||
|
||||
[JsonPropertyName("push_bound")]
|
||||
public bool PushBound { get; set; }
|
||||
|
||||
[JsonPropertyName("paused")]
|
||||
public bool Paused { get; set; }
|
||||
|
||||
[JsonPropertyName("pause_remaining")]
|
||||
public TimeSpan PauseRemaining { get; set; }
|
||||
|
||||
[JsonPropertyName("ts")]
|
||||
public DateTime TimeStamp { get; set; }
|
||||
|
||||
[JsonPropertyName("priority_groups")]
|
||||
public PriorityGroupState[]? PriorityGroups { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State information for a priority group on a pull consumer.
|
||||
/// Mirrors <c>PriorityGroupState</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class PriorityGroupState
|
||||
{
|
||||
[JsonPropertyName("group")]
|
||||
public string Group { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pinned_client_id")]
|
||||
public string? PinnedClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("pinned_ts")]
|
||||
public DateTime PinnedTs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sequence information for consumer delivered/ack_floor positions.
|
||||
/// Mirrors <c>SequenceInfo</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class SequenceInfo
|
||||
{
|
||||
[JsonPropertyName("consumer_seq")]
|
||||
public ulong Consumer { get; set; }
|
||||
|
||||
[JsonPropertyName("stream_seq")]
|
||||
public ulong Stream { get; set; }
|
||||
|
||||
[JsonPropertyName("last_active")]
|
||||
public DateTime? Last { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a consumer.
|
||||
/// Mirrors <c>CreateConsumerRequest</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class CreateConsumerRequest
|
||||
{
|
||||
[JsonPropertyName("stream_name")]
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public ConsumerConfig Config { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public ConsumerAction Action { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the intended action when creating a consumer.
|
||||
/// Mirrors <c>ConsumerAction</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public enum ConsumerAction
|
||||
{
|
||||
/// <summary>Create a new consumer or update if it already exists.</summary>
|
||||
CreateOrUpdate = 0,
|
||||
|
||||
/// <summary>Create a new consumer; fail if it already exists.</summary>
|
||||
Create = 1,
|
||||
|
||||
/// <summary>Update an existing consumer; fail if it does not exist.</summary>
|
||||
Update = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a consumer deletion request.
|
||||
/// Mirrors <c>ConsumerDeleteResponse</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class ConsumerDeleteResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pending pull request waiting in the wait queue.
|
||||
/// Mirrors <c>waitingRequest</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class WaitingRequest
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string? Reply { get; set; }
|
||||
|
||||
/// <summary>Number of messages requested.</summary>
|
||||
public int N { get; set; }
|
||||
|
||||
/// <summary>Number of messages delivered so far.</summary>
|
||||
public int D { get; set; }
|
||||
|
||||
/// <summary>No-wait flag (1 = no wait).</summary>
|
||||
public int NoWait { get; set; }
|
||||
|
||||
public DateTime? Expires { get; set; }
|
||||
|
||||
/// <summary>Max byte limit for this batch.</summary>
|
||||
public int MaxBytes { get; set; }
|
||||
|
||||
/// <summary>Bytes accumulated so far.</summary>
|
||||
public int B { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A circular wait queue for pending pull requests.
|
||||
/// Mirrors <c>waitQueue</c> in server/consumer.go.
|
||||
/// </summary>
|
||||
public sealed class WaitQueue
|
||||
{
|
||||
private readonly List<WaitingRequest> _reqs = new();
|
||||
private int _head;
|
||||
private int _tail;
|
||||
|
||||
/// <summary>Number of pending requests in the queue.</summary>
|
||||
public int Len => _tail - _head;
|
||||
|
||||
/// <summary>Add a waiting request to the tail of the queue.</summary>
|
||||
public void Add(WaitingRequest req)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(req);
|
||||
_reqs.Add(req);
|
||||
_tail++;
|
||||
}
|
||||
|
||||
/// <summary>Peek at the head request without removing it.</summary>
|
||||
public WaitingRequest? Peek()
|
||||
{
|
||||
if (Len == 0)
|
||||
return null;
|
||||
return _reqs[_head];
|
||||
}
|
||||
|
||||
/// <summary>Remove and return the head request.</summary>
|
||||
public WaitingRequest? Pop()
|
||||
{
|
||||
if (Len == 0)
|
||||
return null;
|
||||
|
||||
var req = _reqs[_head++];
|
||||
if (_head > 32 && _head * 2 >= _tail)
|
||||
Compress();
|
||||
return req;
|
||||
}
|
||||
|
||||
/// <summary>Compact the internal backing list to reclaim removed slots.</summary>
|
||||
public void Compress()
|
||||
{
|
||||
if (_head == 0)
|
||||
return;
|
||||
|
||||
_reqs.RemoveRange(0, _head);
|
||||
_tail -= _head;
|
||||
_head = 0;
|
||||
}
|
||||
|
||||
/// <summary>Returns true if the queue is at capacity (head == tail when full).</summary>
|
||||
public bool IsFull(int max)
|
||||
{
|
||||
if (max <= 0)
|
||||
return false;
|
||||
return Len >= max;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cluster membership and leadership information for a stream or consumer.
|
||||
/// Mirrors <c>ClusterInfo</c> in server/consumer.go and server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class ClusterInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("leader")]
|
||||
public string? Leader { get; set; }
|
||||
|
||||
[JsonPropertyName("replicas")]
|
||||
public PeerInfo[]? Replicas { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a peer in a JetStream Raft group.
|
||||
/// Mirrors <c>PeerInfo</c> in server/consumer.go and server/stream.go.
|
||||
/// </summary>
|
||||
public sealed class PeerInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("current")]
|
||||
public bool Current { get; set; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; set; }
|
||||
|
||||
[JsonPropertyName("active")]
|
||||
public TimeSpan Active { get; set; }
|
||||
|
||||
[JsonPropertyName("lag")]
|
||||
public ulong Lag { get; set; }
|
||||
}
|
||||
202
dotnet/src/ZB.MOM.NatsNet.Server/LeafNode/LeafNodeTypes.cs
Normal file
202
dotnet/src/ZB.MOM.NatsNet.Server/LeafNode/LeafNodeTypes.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
// Copyright 2019-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/leafnode.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Session 15: Leaf Nodes
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection leaf-node state embedded in <see cref="ClientConnection"/>
|
||||
/// when the connection kind is <c>Leaf</c>.
|
||||
/// Mirrors Go <c>leaf</c> struct in leafnode.go.
|
||||
/// </summary>
|
||||
internal sealed class Leaf
|
||||
{
|
||||
/// <summary>
|
||||
/// Config for solicited (outbound) leaf connections; null for accepted connections.
|
||||
/// </summary>
|
||||
public LeafNodeCfg? Remote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when we are the spoke side of a hub/spoke leaf pair.
|
||||
/// </summary>
|
||||
public bool IsSpoke { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cluster name of the remote server when we are a hub and the spoke is
|
||||
/// part of a cluster.
|
||||
/// </summary>
|
||||
public string RemoteCluster { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Remote server name or ID.</summary>
|
||||
public string RemoteServer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Domain name of the remote server.</summary>
|
||||
public string RemoteDomain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Account name of the remote server.</summary>
|
||||
public string RemoteAccName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When true, suppresses propagation of east-west interest from other leaf nodes.
|
||||
/// </summary>
|
||||
public bool Isolated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject-interest suppression map shared with the remote side.
|
||||
/// Key = subject, Value = interest count (positive = subscribe, negative = unsubscribe delta).
|
||||
/// </summary>
|
||||
public Dictionary<string, int> Smap { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Short-lived set of subscriptions added during <c>initLeafNodeSmapAndSendSubs</c>
|
||||
/// to detect and avoid double-counting races.
|
||||
/// </summary>
|
||||
public HashSet<Subscription>? Tsub { get; set; }
|
||||
|
||||
/// <summary>Timer that clears <see cref="Tsub"/> after the initialization window.</summary>
|
||||
public Timer? Tsubt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Selected compression mode, which may differ from the server-configured mode.
|
||||
/// </summary>
|
||||
public string Compression { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gateway-mapped reply subscription used for GW reply routing via leaf nodes.
|
||||
/// </summary>
|
||||
public Subscription? GwSub { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime configuration for a remote (solicited) leaf-node connection.
|
||||
/// Wraps <see cref="RemoteLeafOpts"/> with connection-attempt state and a
|
||||
/// reader-writer lock for concurrent access.
|
||||
/// Mirrors Go <c>leafNodeCfg</c> struct in leafnode.go.
|
||||
/// Replaces the stub that was in <c>NatsServerTypes.cs</c>.
|
||||
/// </summary>
|
||||
public sealed class LeafNodeCfg
|
||||
{
|
||||
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Embedded RemoteLeafOpts fields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>The raw remote options this cfg was constructed from.</summary>
|
||||
public RemoteLeafOpts? RemoteOpts { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime connection-attempt fields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Resolved URLs to attempt connections to.</summary>
|
||||
public List<Uri> Urls { get; set; } = [];
|
||||
|
||||
/// <summary>Currently selected URL from <see cref="Urls"/>.</summary>
|
||||
public Uri? CurUrl { get; set; }
|
||||
|
||||
/// <summary>TLS server name override for SNI.</summary>
|
||||
public string TlsName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Username for authentication (resolved from credentials or options).</summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Password for authentication (resolved from credentials or options).</summary>
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Publish/subscribe permission overrides for this connection.</summary>
|
||||
public Permissions? Perms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Delay before the next connection attempt (e.g. during loop-detection back-off).
|
||||
/// </summary>
|
||||
public TimeSpan ConnDelay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timer used to trigger JetStream account migration for this leaf.
|
||||
/// </summary>
|
||||
public Timer? JsMigrateTimer { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Forwarded properties from RemoteLeafOpts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public string LocalAccount { get => RemoteOpts?.LocalAccount ?? string.Empty; }
|
||||
public bool NoRandomize { get => RemoteOpts?.NoRandomize ?? false; }
|
||||
public string Credentials { get => RemoteOpts?.Credentials ?? string.Empty; }
|
||||
public bool Disabled { get => RemoteOpts?.Disabled ?? false; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lock helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void AcquireReadLock() => _lock.EnterReadLock();
|
||||
public void ReleaseReadLock() => _lock.ExitReadLock();
|
||||
public void AcquireWriteLock() => _lock.EnterWriteLock();
|
||||
public void ReleaseWriteLock() => _lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONNECT protocol payload sent by a leaf-node connection.
|
||||
/// Fields map 1-to-1 with the JSON tags in Go's <c>leafConnectInfo</c>.
|
||||
/// Mirrors Go <c>leafConnectInfo</c> struct in leafnode.go.
|
||||
/// </summary>
|
||||
internal sealed class LeafConnectInfo
|
||||
{
|
||||
[JsonPropertyName("version")] public string Version { get; set; } = string.Empty;
|
||||
[JsonPropertyName("nkey")] public string Nkey { get; set; } = string.Empty;
|
||||
[JsonPropertyName("jwt")] public string Jwt { get; set; } = string.Empty;
|
||||
[JsonPropertyName("sig")] public string Sig { get; set; } = string.Empty;
|
||||
[JsonPropertyName("user")] public string User { get; set; } = string.Empty;
|
||||
[JsonPropertyName("pass")] public string Pass { get; set; } = string.Empty;
|
||||
[JsonPropertyName("auth_token")] public string Token { get; set; } = string.Empty;
|
||||
[JsonPropertyName("server_id")] public string Id { get; set; } = string.Empty;
|
||||
[JsonPropertyName("domain")] public string Domain { get; set; } = string.Empty;
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("is_hub")] public bool Hub { get; set; }
|
||||
[JsonPropertyName("cluster")] public string Cluster { get; set; } = string.Empty;
|
||||
[JsonPropertyName("headers")] public bool Headers { get; set; }
|
||||
[JsonPropertyName("jetstream")] public bool JetStream { get; set; }
|
||||
[JsonPropertyName("deny_pub")] public string[] DenyPub { get; set; } = [];
|
||||
[JsonPropertyName("isolate")] public bool Isolate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compression mode string. The legacy boolean field was never used; this
|
||||
/// string field uses a different JSON tag to avoid conflicts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("compress_mode")] public string Compression { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Used only to detect wrong-port connections (client connecting to leaf port).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gateway")] public string Gateway { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Account name the remote is binding to on the accept side.</summary>
|
||||
[JsonPropertyName("remote_account")] public string RemoteAccount { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Protocol version sent by the soliciting side so the accepting side knows
|
||||
/// which features are supported (e.g. message tracing).
|
||||
/// </summary>
|
||||
[JsonPropertyName("protocol")] public int Proto { get; set; }
|
||||
}
|
||||
465
dotnet/src/ZB.MOM.NatsNet.Server/MessageTrace/MsgTraceTypes.cs
Normal file
465
dotnet/src/ZB.MOM.NatsNet.Server/MessageTrace/MsgTraceTypes.cs
Normal file
@@ -0,0 +1,465 @@
|
||||
// Copyright 2024-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/msgtrace.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Message-trace header name constants
|
||||
// Mirrors Go const block at top of server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// NATS message-trace header names and special sentinel values.
|
||||
/// Mirrors Go const block in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public static class MsgTraceHeaders
|
||||
{
|
||||
/// <summary>Header that carries the trace destination subject. Mirrors Go <c>MsgTraceDest</c>.</summary>
|
||||
public const string MsgTraceDest = "Nats-Trace-Dest";
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel value placed in the trace-dest header to disable tracing
|
||||
/// (must be an invalid NATS subject). Mirrors Go <c>MsgTraceDestDisabled</c>.
|
||||
/// </summary>
|
||||
public const string MsgTraceDestDisabled = "trace disabled";
|
||||
|
||||
/// <summary>Header used for hop-count tracking across servers. Mirrors Go <c>MsgTraceHop</c>.</summary>
|
||||
public const string MsgTraceHop = "Nats-Trace-Hop";
|
||||
|
||||
/// <summary>Header that carries the originating account name. Mirrors Go <c>MsgTraceOriginAccount</c>.</summary>
|
||||
public const string MsgTraceOriginAccount = "Nats-Trace-Origin-Account";
|
||||
|
||||
/// <summary>
|
||||
/// When set to a truthy value, the message is consumed only for tracing
|
||||
/// and not delivered to subscribers. Mirrors Go <c>MsgTraceOnly</c>.
|
||||
/// </summary>
|
||||
public const string MsgTraceOnly = "Nats-Trace-Only";
|
||||
|
||||
/// <summary>
|
||||
/// W3C trace-context parent header. NATS no longer lower-cases this but
|
||||
/// accepts it in any case. Mirrors Go <c>traceParentHdr</c> (internal).
|
||||
/// </summary>
|
||||
public const string TraceParentHdr = "traceparent";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceType — discriminator string for polymorphic trace event lists
|
||||
// Mirrors Go <c>MsgTraceType string</c> in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Discriminator string identifying the concrete type of a trace event
|
||||
/// within a <see cref="MsgTraceEvents"/> list.
|
||||
/// Mirrors Go <c>MsgTraceType string</c> and its constants in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceType
|
||||
{
|
||||
private readonly string _value;
|
||||
private MsgTraceType(string value) => _value = value;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => _value;
|
||||
|
||||
public static implicit operator MsgTraceType(string value) => new(value);
|
||||
public static implicit operator string(MsgTraceType t) => t._value;
|
||||
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is MsgTraceType other && _value == other._value;
|
||||
|
||||
public override int GetHashCode() => _value.GetHashCode();
|
||||
|
||||
// ---- Well-known type constants (mirror Go const block) ----
|
||||
|
||||
/// <summary>Ingress event. Mirrors Go <c>MsgTraceIngressType = "in"</c>.</summary>
|
||||
public static readonly MsgTraceType Ingress = new("in");
|
||||
|
||||
/// <summary>Subject-mapping event. Mirrors Go <c>MsgTraceSubjectMappingType = "sm"</c>.</summary>
|
||||
public static readonly MsgTraceType SubjectMapping = new("sm");
|
||||
|
||||
/// <summary>Stream-export event. Mirrors Go <c>MsgTraceStreamExportType = "se"</c>.</summary>
|
||||
public static readonly MsgTraceType StreamExport = new("se");
|
||||
|
||||
/// <summary>Service-import event. Mirrors Go <c>MsgTraceServiceImportType = "si"</c>.</summary>
|
||||
public static readonly MsgTraceType ServiceImport = new("si");
|
||||
|
||||
/// <summary>JetStream storage event. Mirrors Go <c>MsgTraceJetStreamType = "js"</c>.</summary>
|
||||
public static readonly MsgTraceType JetStream = new("js");
|
||||
|
||||
/// <summary>Egress (delivery) event. Mirrors Go <c>MsgTraceEgressType = "eg"</c>.</summary>
|
||||
public static readonly MsgTraceType Egress = new("eg");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMsgTrace — interface for polymorphic trace events
|
||||
// Mirrors Go <c>MsgTrace interface</c> in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface implemented by all concrete message-trace event types.
|
||||
/// Enables polymorphic handling of the <see cref="MsgTraceEvents"/> list.
|
||||
/// Mirrors Go <c>MsgTrace interface</c> in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public interface IMsgTrace
|
||||
{
|
||||
/// <summary>Returns the discriminator type string for this trace event.</summary>
|
||||
string Typ();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceBase — shared fields present in every trace event
|
||||
// Mirrors Go <c>MsgTraceBase</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Common base fields shared by all concrete message-trace event types.
|
||||
/// Mirrors Go <c>MsgTraceBase</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public class MsgTraceBase : IMsgTrace
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ts")]
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string Typ() => Type;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceIngress — client / route / gateway / leaf connection ingress event
|
||||
// Mirrors Go <c>MsgTraceIngress</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records the point at which a message was received by the server from a
|
||||
/// client, route, gateway, or leaf connection.
|
||||
/// Mirrors Go <c>MsgTraceIngress</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceIngress : MsgTraceBase
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public int Kind { get; set; }
|
||||
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subj")]
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceSubjectMapping — subject-mapping rewrite event
|
||||
// Mirrors Go <c>MsgTraceSubjectMapping</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records a subject-mapping rewrite applied to an in-flight message.
|
||||
/// Mirrors Go <c>MsgTraceSubjectMapping</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceSubjectMapping : MsgTraceBase
|
||||
{
|
||||
[JsonPropertyName("to")]
|
||||
public string MappedTo { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceStreamExport — stream export / cross-account delivery event
|
||||
// Mirrors Go <c>MsgTraceStreamExport</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records delivery of a message to a stream-export destination account.
|
||||
/// Mirrors Go <c>MsgTraceStreamExport</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceStreamExport : MsgTraceBase
|
||||
{
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string To { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceServiceImport — service import routing event
|
||||
// Mirrors Go <c>MsgTraceServiceImport</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records routing of a message via a service-import from one account to
|
||||
/// another.
|
||||
/// Mirrors Go <c>MsgTraceServiceImport</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceServiceImport : MsgTraceBase
|
||||
{
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public string From { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string To { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceJetStream — JetStream storage event
|
||||
// Mirrors Go <c>MsgTraceJetStream</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records the attempt (and outcome) of storing or delivering a message
|
||||
/// to a JetStream stream.
|
||||
/// Mirrors Go <c>MsgTraceJetStream</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceJetStream : MsgTraceBase
|
||||
{
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("nointerest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool NoInterest { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceEgress — outbound delivery event
|
||||
// Mirrors Go <c>MsgTraceEgress</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Records the outbound delivery of a message to a subscriber, route,
|
||||
/// gateway, or leaf connection.
|
||||
/// Mirrors Go <c>MsgTraceEgress</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceEgress : MsgTraceBase
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public int Kind { get; set; }
|
||||
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("hop")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Hop { get; set; }
|
||||
|
||||
[JsonPropertyName("acc")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Account { get; set; }
|
||||
|
||||
[JsonPropertyName("sub")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subscription { get; set; }
|
||||
|
||||
[JsonPropertyName("queue")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Queue { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional link to the <see cref="MsgTraceEvent"/> produced by the remote
|
||||
/// server that received this egress message (route/leaf/gateway hop).
|
||||
/// Not serialised. Mirrors Go <c>Link *MsgTraceEvent</c>.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public MsgTraceEvent? Link { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceEvents — polymorphic list with custom JSON deserialiser
|
||||
// Mirrors Go <c>MsgTraceEvents []MsgTrace</c> and its UnmarshalJSON in msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Custom JSON converter that deserialises a <c>MsgTraceEvents</c> JSON array
|
||||
/// into the correct concrete <see cref="IMsgTrace"/> subtype, using the
|
||||
/// <c>"type"</c> discriminator field.
|
||||
/// Mirrors Go <c>MsgTraceEvents.UnmarshalJSON</c> in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceEventsConverter : JsonConverter<List<IMsgTrace>>
|
||||
{
|
||||
private static readonly Dictionary<string, Func<JsonElement, IMsgTrace>> Factories = new()
|
||||
{
|
||||
["in"] = e => e.Deserialize<MsgTraceIngress>()!,
|
||||
["sm"] = e => e.Deserialize<MsgTraceSubjectMapping>()!,
|
||||
["se"] = e => e.Deserialize<MsgTraceStreamExport>()!,
|
||||
["si"] = e => e.Deserialize<MsgTraceServiceImport>()!,
|
||||
["js"] = e => e.Deserialize<MsgTraceJetStream>()!,
|
||||
["eg"] = e => e.Deserialize<MsgTraceEgress>()!,
|
||||
};
|
||||
|
||||
public override List<IMsgTrace> Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
var result = new List<IMsgTrace>();
|
||||
using var doc = JsonDocument.ParseValue(ref reader);
|
||||
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (!element.TryGetProperty("type", out var typeProp))
|
||||
throw new JsonException("MsgTrace element missing 'type' field.");
|
||||
|
||||
var typeStr = typeProp.GetString() ?? string.Empty;
|
||||
|
||||
if (!Factories.TryGetValue(typeStr, out var factory))
|
||||
throw new JsonException($"Unknown MsgTrace type '{typeStr}'.");
|
||||
|
||||
result.Add(factory(element));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
List<IMsgTrace> value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in value)
|
||||
JsonSerializer.Serialize(writer, item, item.GetType(), options);
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceRequest — the original request metadata included in a trace event
|
||||
// Mirrors Go <c>MsgTraceRequest</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Captures the headers and size of the original message that triggered a
|
||||
/// trace event.
|
||||
/// Mirrors Go <c>MsgTraceRequest</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Original message headers, preserving header-name casing.
|
||||
/// Mirrors Go <c>Header map[string][]string</c> (not http.Header, so casing is preserved).
|
||||
/// </summary>
|
||||
[JsonPropertyName("header")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, List<string>>? Header { get; set; }
|
||||
|
||||
[JsonPropertyName("msgsize")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int MsgSize { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgTraceEvent — top-level trace event published to the trace destination
|
||||
// Mirrors Go <c>MsgTraceEvent</c> struct in server/msgtrace.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The top-level message-trace advisory published to the trace destination
|
||||
/// subject. Contains server identity, the original request metadata, the
|
||||
/// hop count, and the ordered list of trace events.
|
||||
/// Mirrors Go <c>MsgTraceEvent</c> struct in server/msgtrace.go.
|
||||
/// </summary>
|
||||
public sealed class MsgTraceEvent
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public ServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("request")]
|
||||
public MsgTraceRequest Request { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("hops")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int Hops { get; set; }
|
||||
|
||||
[JsonPropertyName("events")]
|
||||
[JsonConverter(typeof(MsgTraceEventsConverter))]
|
||||
public List<IMsgTrace> Events { get; set; } = [];
|
||||
|
||||
// ---- Convenience accessors (mirrors Go helper methods on MsgTraceEvent) ----
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first event if it is a <see cref="MsgTraceIngress"/>, else null.
|
||||
/// Mirrors Go <c>MsgTraceEvent.Ingress()</c>.
|
||||
/// </summary>
|
||||
public MsgTraceIngress? Ingress() =>
|
||||
Events.Count > 0 ? Events[0] as MsgTraceIngress : null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first <see cref="MsgTraceSubjectMapping"/> in the event list, or null.
|
||||
/// Mirrors Go <c>MsgTraceEvent.SubjectMapping()</c>.
|
||||
/// </summary>
|
||||
public MsgTraceSubjectMapping? SubjectMapping() =>
|
||||
Events.OfType<MsgTraceSubjectMapping>().FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all <see cref="MsgTraceStreamExport"/> events.
|
||||
/// Mirrors Go <c>MsgTraceEvent.StreamExports()</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MsgTraceStreamExport> StreamExports() =>
|
||||
Events.OfType<MsgTraceStreamExport>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all <see cref="MsgTraceServiceImport"/> events.
|
||||
/// Mirrors Go <c>MsgTraceEvent.ServiceImports()</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MsgTraceServiceImport> ServiceImports() =>
|
||||
Events.OfType<MsgTraceServiceImport>().ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first <see cref="MsgTraceJetStream"/> event, or null.
|
||||
/// Mirrors Go <c>MsgTraceEvent.JetStream()</c>.
|
||||
/// </summary>
|
||||
public MsgTraceJetStream? JetStream() =>
|
||||
Events.OfType<MsgTraceJetStream>().FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all <see cref="MsgTraceEgress"/> events.
|
||||
/// Mirrors Go <c>MsgTraceEvent.Egresses()</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MsgTraceEgress> Egresses() =>
|
||||
Events.OfType<MsgTraceEgress>().ToList();
|
||||
}
|
||||
294
dotnet/src/ZB.MOM.NatsNet.Server/Monitor/MonitorSortOptions.cs
Normal file
294
dotnet/src/ZB.MOM.NatsNet.Server/Monitor/MonitorSortOptions.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
// Copyright 2013-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/monitor_sort_opts.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// SortOpt — string wrapper type for connection-list sort options
|
||||
// Mirrors Go <c>SortOpt string</c> in server/monitor_sort_opts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed sort option for <see cref="ConnzOptions.Sort"/>.
|
||||
/// Wraps a raw string value corresponding to the JSON sort key.
|
||||
/// Mirrors Go <c>SortOpt string</c> in server/monitor_sort_opts.go.
|
||||
/// </summary>
|
||||
public sealed class SortOpt
|
||||
{
|
||||
private readonly string _value;
|
||||
|
||||
private SortOpt(string value) => _value = value;
|
||||
|
||||
/// <summary>Returns the raw sort-option string value.</summary>
|
||||
public override string ToString() => _value;
|
||||
|
||||
/// <summary>Allows implicit conversion from a string literal.</summary>
|
||||
public static implicit operator SortOpt(string value) => new(value);
|
||||
|
||||
/// <summary>Allows implicit conversion back to a plain string.</summary>
|
||||
public static implicit operator string(SortOpt opt) => opt._value;
|
||||
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is SortOpt other && _value == other._value;
|
||||
|
||||
public override int GetHashCode() => _value.GetHashCode();
|
||||
|
||||
// ---- Well-known sort-option constants ----
|
||||
// Mirrors Go const block in monitor_sort_opts.go.
|
||||
|
||||
/// <summary>Sort by connection ID (ascending). Mirrors Go <c>ByCid = "cid"</c>.</summary>
|
||||
public static readonly SortOpt ByCid = new("cid");
|
||||
|
||||
/// <summary>Sort by connection start time (same as ByCid). Mirrors Go <c>ByStart = "start"</c>.</summary>
|
||||
public static readonly SortOpt ByStart = new("start");
|
||||
|
||||
/// <summary>Sort by number of subscriptions (descending). Mirrors Go <c>BySubs = "subs"</c>.</summary>
|
||||
public static readonly SortOpt BySubs = new("subs");
|
||||
|
||||
/// <summary>Sort by pending bytes waiting to be sent (descending). Mirrors Go <c>ByPending = "pending"</c>.</summary>
|
||||
public static readonly SortOpt ByPending = new("pending");
|
||||
|
||||
/// <summary>Sort by number of outbound messages (descending). Mirrors Go <c>ByOutMsgs = "msgs_to"</c>.</summary>
|
||||
public static readonly SortOpt ByOutMsgs = new("msgs_to");
|
||||
|
||||
/// <summary>Sort by number of inbound messages (descending). Mirrors Go <c>ByInMsgs = "msgs_from"</c>.</summary>
|
||||
public static readonly SortOpt ByInMsgs = new("msgs_from");
|
||||
|
||||
/// <summary>Sort by bytes sent (descending). Mirrors Go <c>ByOutBytes = "bytes_to"</c>.</summary>
|
||||
public static readonly SortOpt ByOutBytes = new("bytes_to");
|
||||
|
||||
/// <summary>Sort by bytes received (descending). Mirrors Go <c>ByInBytes = "bytes_from"</c>.</summary>
|
||||
public static readonly SortOpt ByInBytes = new("bytes_from");
|
||||
|
||||
/// <summary>Sort by last activity time (descending). Mirrors Go <c>ByLast = "last"</c>.</summary>
|
||||
public static readonly SortOpt ByLast = new("last");
|
||||
|
||||
/// <summary>Sort by idle duration (descending). Mirrors Go <c>ByIdle = "idle"</c>.</summary>
|
||||
public static readonly SortOpt ByIdle = new("idle");
|
||||
|
||||
/// <summary>Sort by uptime (descending). Mirrors Go <c>ByUptime = "uptime"</c>.</summary>
|
||||
public static readonly SortOpt ByUptime = new("uptime");
|
||||
|
||||
/// <summary>Sort by stop time — only valid on closed connections. Mirrors Go <c>ByStop = "stop"</c>.</summary>
|
||||
public static readonly SortOpt ByStop = new("stop");
|
||||
|
||||
/// <summary>Sort by close reason — only valid on closed connections. Mirrors Go <c>ByReason = "reason"</c>.</summary>
|
||||
public static readonly SortOpt ByReason = new("reason");
|
||||
|
||||
/// <summary>Sort by round-trip time (descending). Mirrors Go <c>ByRTT = "rtt"</c>.</summary>
|
||||
public static readonly SortOpt ByRtt = new("rtt");
|
||||
|
||||
private static readonly HashSet<string> ValidValues =
|
||||
[
|
||||
"", "cid", "start", "subs", "pending",
|
||||
"msgs_to", "msgs_from", "bytes_to", "bytes_from",
|
||||
"last", "idle", "uptime", "stop", "reason", "rtt"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this sort option is a recognised value.
|
||||
/// Mirrors Go <c>SortOpt.IsValid()</c> in monitor_sort_opts.go.
|
||||
/// </summary>
|
||||
public bool IsValid() => ValidValues.Contains(_value);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConnInfos — sortable list wrapper for ConnInfo pointers
|
||||
// Mirrors Go <c>ConnInfos []*ConnInfo</c> in monitor_sort_opts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A list of <see cref="ConnInfo"/> objects that can be sorted using one of
|
||||
/// the <c>SortBy*</c> comparers defined in this file.
|
||||
/// Mirrors Go <c>ConnInfos []*ConnInfo</c> in server/monitor_sort_opts.go.
|
||||
/// </summary>
|
||||
public sealed class ConnInfos : List<ConnInfo>
|
||||
{
|
||||
public ConnInfos() { }
|
||||
public ConnInfos(IEnumerable<ConnInfo> items) : base(items) { }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IComparer<ConnInfo> implementations — one per sort option
|
||||
// Each class mirrors the corresponding Less() method in monitor_sort_opts.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>Sort by connection ID (ascending). Mirrors Go <c>SortByCid</c>.</summary>
|
||||
public sealed class SortByCid : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByCid Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.Cid.CompareTo(y.Cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by number of subscriptions (ascending for underlying sort; caller reverses if needed).</summary>
|
||||
/// Mirrors Go <c>SortBySubs</c>.
|
||||
public sealed class SortBySubs : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortBySubs Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.NumSubs.CompareTo(y.NumSubs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by pending bytes. Mirrors Go <c>SortByPending</c>.</summary>
|
||||
public sealed class SortByPending : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByPending Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.Pending.CompareTo(y.Pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by outbound message count. Mirrors Go <c>SortByOutMsgs</c>.</summary>
|
||||
public sealed class SortByOutMsgs : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByOutMsgs Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.OutMsgs.CompareTo(y.OutMsgs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by inbound message count. Mirrors Go <c>SortByInMsgs</c>.</summary>
|
||||
public sealed class SortByInMsgs : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByInMsgs Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.InMsgs.CompareTo(y.InMsgs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by outbound bytes. Mirrors Go <c>SortByOutBytes</c>.</summary>
|
||||
public sealed class SortByOutBytes : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByOutBytes Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.OutBytes.CompareTo(y.OutBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by inbound bytes. Mirrors Go <c>SortByInBytes</c>.</summary>
|
||||
public sealed class SortByInBytes : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByInBytes Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.InBytes.CompareTo(y.InBytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by last activity timestamp. Mirrors Go <c>SortByLast</c>.</summary>
|
||||
public sealed class SortByLast : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByLast Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.LastActivity.CompareTo(y.LastActivity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort by idle duration (time since last activity), relative to a supplied
|
||||
/// reference time. Mirrors Go <c>SortByIdle</c>.
|
||||
/// </summary>
|
||||
public sealed class SortByIdle : IComparer<ConnInfo>
|
||||
{
|
||||
private readonly DateTime _now;
|
||||
|
||||
public SortByIdle(DateTime now) => _now = now;
|
||||
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
var idleX = _now - x.LastActivity;
|
||||
var idleY = _now - y.LastActivity;
|
||||
return idleX.CompareTo(idleY);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort by uptime (time the connection has been open), relative to a supplied
|
||||
/// reference time. Mirrors Go <c>SortByUptime</c>.
|
||||
/// </summary>
|
||||
public sealed class SortByUptime : IComparer<ConnInfo>
|
||||
{
|
||||
private readonly DateTime _now;
|
||||
|
||||
public SortByUptime(DateTime now) => _now = now;
|
||||
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
var uptimeX = (x.Stop is null || x.Stop == default) ? _now - x.Start : x.Stop.Value - x.Start;
|
||||
var uptimeY = (y.Stop is null || y.Stop == default) ? _now - y.Start : y.Stop.Value - y.Start;
|
||||
return uptimeX.CompareTo(uptimeY);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by stop time (closed connections only). Mirrors Go <c>SortByStop</c>.</summary>
|
||||
public sealed class SortByStop : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByStop Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
// If either stop is null treat as zero (shouldn't happen for closed-only queries)
|
||||
var stopX = x.Stop ?? DateTime.MinValue;
|
||||
var stopY = y.Stop ?? DateTime.MinValue;
|
||||
return stopX.CompareTo(stopY);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sort by close reason string. Mirrors Go <c>SortByReason</c>.</summary>
|
||||
public sealed class SortByReason : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByReason Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return string.Compare(x.Reason, y.Reason, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort by round-trip time (nanoseconds, internal field).
|
||||
/// Mirrors Go <c>SortByRTT</c>.
|
||||
/// </summary>
|
||||
public sealed class SortByRtt : IComparer<ConnInfo>
|
||||
{
|
||||
public static readonly SortByRtt Instance = new();
|
||||
public int Compare(ConnInfo? x, ConnInfo? y)
|
||||
{
|
||||
if (x is null || y is null) return 0;
|
||||
return x.RttNanos.CompareTo(y.RttNanos);
|
||||
}
|
||||
}
|
||||
387
dotnet/src/ZB.MOM.NatsNet.Server/Monitor/MonitorTypes.cs
Normal file
387
dotnet/src/ZB.MOM.NatsNet.Server/Monitor/MonitorTypes.cs
Normal file
@@ -0,0 +1,387 @@
|
||||
// Copyright 2013-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/monitor.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Monitor list size defaults
|
||||
// Mirrors Go const block near top of monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Default sizes for monitoring API response lists.
|
||||
/// Mirrors Go constants in server/monitor.go.
|
||||
/// </summary>
|
||||
public static class MonitorDefaults
|
||||
{
|
||||
/// <summary>Default maximum number of connection entries returned. Mirrors Go <c>DefaultConnListSize = 1024</c>.</summary>
|
||||
public const int DefaultConnListSize = 1024;
|
||||
|
||||
/// <summary>Default maximum number of subscription entries returned. Mirrors Go <c>DefaultSubListSize = 1024</c>.</summary>
|
||||
public const int DefaultSubListSize = 1024;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConnState — connection state filter for Connz queries
|
||||
// Mirrors Go <c>ConnState</c> and its iota constants in monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Filter applied to connection-list queries to select open, closed, or
|
||||
/// all connections.
|
||||
/// Mirrors Go <c>ConnState</c> in server/monitor.go.
|
||||
/// </summary>
|
||||
public enum ConnState
|
||||
{
|
||||
/// <summary>Only return open (active) connections. Mirrors Go <c>ConnOpen = 0</c>.</summary>
|
||||
ConnOpen = 0,
|
||||
|
||||
/// <summary>Only return closed connections. Mirrors Go <c>ConnClosed</c>.</summary>
|
||||
ConnClosed = 1,
|
||||
|
||||
/// <summary>Return all connections, open or closed. Mirrors Go <c>ConnAll</c>.</summary>
|
||||
ConnAll = 2,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConnzOptions — query options for the Connz endpoint
|
||||
// Mirrors Go <c>ConnzOptions</c> struct in server/monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Options that control the output of a <c>Connz</c> monitoring query.
|
||||
/// Mirrors Go <c>ConnzOptions</c> struct in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class ConnzOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// How to sort results. Only <c>ByCid</c> is ascending; all others are
|
||||
/// descending. Mirrors Go <c>Sort SortOpt</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sort")]
|
||||
public SortOpt Sort { get; set; } = SortOpt.ByCid;
|
||||
|
||||
/// <summary>When true, usernames are included in results. Mirrors Go <c>Username bool</c>.</summary>
|
||||
[JsonPropertyName("auth")]
|
||||
public bool Username { get; set; }
|
||||
|
||||
/// <summary>When true, subscription subjects are listed. Mirrors Go <c>Subscriptions bool</c>.</summary>
|
||||
[JsonPropertyName("subscriptions")]
|
||||
public bool Subscriptions { get; set; }
|
||||
|
||||
/// <summary>When true, verbose subscription detail is included. Mirrors Go <c>SubscriptionsDetail bool</c>.</summary>
|
||||
[JsonPropertyName("subscriptions_detail")]
|
||||
public bool SubscriptionsDetail { get; set; }
|
||||
|
||||
/// <summary>Zero-based offset for pagination. Mirrors Go <c>Offset int</c>.</summary>
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; set; }
|
||||
|
||||
/// <summary>Maximum number of connections to return. Mirrors Go <c>Limit int</c>.</summary>
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; set; }
|
||||
|
||||
/// <summary>Filter for a specific client connection by CID. Mirrors Go <c>CID uint64</c>.</summary>
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
|
||||
/// <summary>Filter for a specific MQTT client ID. Mirrors Go <c>MQTTClient string</c>.</summary>
|
||||
[JsonPropertyName("mqtt_client")]
|
||||
public string MqttClient { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Connection state filter. Mirrors Go <c>State ConnState</c>.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public ConnState State { get; set; } = ConnState.ConnOpen;
|
||||
|
||||
/// <summary>Filter by username. Mirrors Go <c>User string</c>.</summary>
|
||||
[JsonPropertyName("user")]
|
||||
public string User { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Filter by account name. Mirrors Go <c>Account string</c>.</summary>
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Filter by subject interest (requires Account filter). Mirrors Go <c>FilterSubject string</c>.</summary>
|
||||
[JsonPropertyName("filter_subject")]
|
||||
public string FilterSubject { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connz — top-level connection list monitoring response
|
||||
// Mirrors Go <c>Connz</c> struct in server/monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Top-level response type for the <c>/connz</c> monitoring endpoint.
|
||||
/// Contains the current connection list and pagination metadata.
|
||||
/// Mirrors Go <c>Connz</c> struct in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class Connz
|
||||
{
|
||||
[JsonPropertyName("server_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("now")]
|
||||
public DateTime Now { get; set; }
|
||||
|
||||
[JsonPropertyName("num_connections")]
|
||||
public int NumConns { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; set; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; set; }
|
||||
|
||||
[JsonPropertyName("connections")]
|
||||
public List<ConnInfo> Conns { get; set; } = [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConnInfo — per-connection detail record
|
||||
// Mirrors Go <c>ConnInfo</c> struct in server/monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Detailed information about a single client connection, as returned by the
|
||||
/// <c>/connz</c> monitoring endpoint.
|
||||
/// Mirrors Go <c>ConnInfo</c> struct in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class ConnInfo
|
||||
{
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Kind { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("ip")]
|
||||
public string Ip { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("start")]
|
||||
public DateTime Start { get; set; }
|
||||
|
||||
[JsonPropertyName("last_activity")]
|
||||
public DateTime LastActivity { get; set; }
|
||||
|
||||
[JsonPropertyName("stop")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTime? Stop { get; set; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
[JsonPropertyName("rtt")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Rtt { get; set; }
|
||||
|
||||
[JsonPropertyName("uptime")]
|
||||
public string Uptime { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("idle")]
|
||||
public string Idle { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pending_bytes")]
|
||||
public int Pending { get; set; }
|
||||
|
||||
[JsonPropertyName("in_msgs")]
|
||||
public long InMsgs { get; set; }
|
||||
|
||||
[JsonPropertyName("out_msgs")]
|
||||
public long OutMsgs { get; set; }
|
||||
|
||||
[JsonPropertyName("in_bytes")]
|
||||
public long InBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("out_bytes")]
|
||||
public long OutBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("stalls")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public long Stalls { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Lang { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_version")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TlsVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_cipher_suite")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TlsCipher { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_peer_certs")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<TlsPeerCert>? TlsPeerCerts { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_first")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsFirst { get; set; }
|
||||
|
||||
[JsonPropertyName("authorized_user")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? AuthorizedUser { get; set; }
|
||||
|
||||
[JsonPropertyName("account")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Account { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions_list")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<string>? Subs { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions_list_detail")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<SubDetail>? SubsDetail { get; set; }
|
||||
|
||||
[JsonPropertyName("jwt")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Jwt { get; set; }
|
||||
|
||||
[JsonPropertyName("issuer_key")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? IssuerKey { get; set; }
|
||||
|
||||
[JsonPropertyName("name_tag")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? NameTag { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
[JsonPropertyName("mqtt_client")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MqttClient { get; set; }
|
||||
|
||||
[JsonPropertyName("proxy")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ProxyInfo? Proxy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal field used for fast RTT-based sorting.
|
||||
/// Mirrors Go <c>rtt int64</c> unexported field in ConnInfo.
|
||||
/// Not serialised.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal long RttNanos { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ProxyInfo — proxy connection metadata
|
||||
// Mirrors Go <c>ProxyInfo</c> struct in server/monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Information about a proxied connection (e.g. HAProxy PROXY protocol).
|
||||
/// Mirrors Go <c>ProxyInfo</c> struct in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class ProxyInfo
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TlsPeerCert — TLS peer certificate summary
|
||||
// Mirrors Go <c>TLSPeerCert</c> struct in server/monitor.go.
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Basic information about a TLS peer certificate.
|
||||
/// Mirrors Go <c>TLSPeerCert</c> struct in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class TlsPeerCert
|
||||
{
|
||||
[JsonPropertyName("subject")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("spki_sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SubjectPkiSha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("cert_sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? CertSha256 { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SubDetail — verbose subscription information
|
||||
// Mirrors Go <c>SubDetail</c> struct in server/monitor.go (line ~961).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Verbose information about a single subscription, included in detailed
|
||||
/// connection or account monitoring responses.
|
||||
/// Mirrors Go <c>SubDetail</c> struct in server/monitor.go.
|
||||
/// </summary>
|
||||
public sealed class SubDetail
|
||||
{
|
||||
[JsonPropertyName("account")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Account { get; set; }
|
||||
|
||||
[JsonPropertyName("account_tag")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? AccountTag { get; set; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("qgroup")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Queue { get; set; }
|
||||
|
||||
[JsonPropertyName("sid")]
|
||||
public string Sid { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("msgs")]
|
||||
public long Msgs { get; set; }
|
||||
|
||||
[JsonPropertyName("max")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public long Max { get; set; }
|
||||
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
}
|
||||
271
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttConstants.cs
Normal file
271
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttConstants.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/mqtt.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Mqtt;
|
||||
|
||||
// References to "spec" here are from https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.pdf
|
||||
|
||||
/// <summary>
|
||||
/// MQTT control packet type byte values.
|
||||
/// Mirrors the <c>mqttPacket*</c> constants in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal static class MqttPacket
|
||||
{
|
||||
public const byte Connect = 0x10;
|
||||
public const byte ConnectAck = 0x20;
|
||||
public const byte Pub = 0x30;
|
||||
public const byte PubAck = 0x40;
|
||||
public const byte PubRec = 0x50;
|
||||
public const byte PubRel = 0x60;
|
||||
public const byte PubComp = 0x70;
|
||||
public const byte Sub = 0x80;
|
||||
public const byte SubAck = 0x90;
|
||||
public const byte Unsub = 0xA0;
|
||||
public const byte UnsubAck = 0xB0;
|
||||
public const byte Ping = 0xC0;
|
||||
public const byte PingResp = 0xD0;
|
||||
public const byte Disconnect = 0xE0;
|
||||
public const byte Mask = 0xF0;
|
||||
public const byte FlagMask = 0x0F;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT CONNECT packet flag byte values.
|
||||
/// Mirrors the <c>mqttConnFlag*</c> constants in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal static class MqttConnectFlag
|
||||
{
|
||||
public const byte Reserved = 0x01;
|
||||
public const byte CleanSession = 0x02;
|
||||
public const byte WillFlag = 0x04;
|
||||
public const byte WillQoS = 0x18;
|
||||
public const byte WillRetain = 0x20;
|
||||
public const byte PasswordFlag = 0x40;
|
||||
public const byte UsernameFlag = 0x80;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT PUBLISH packet flag byte values.
|
||||
/// Mirrors the <c>mqttPubFlag*</c> and <c>mqttPubQoS*</c> constants in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal static class MqttPubFlag
|
||||
{
|
||||
public const byte Retain = 0x01;
|
||||
public const byte QoS = 0x06;
|
||||
public const byte Dup = 0x08;
|
||||
public const byte QoS1 = 0x1 << 1;
|
||||
public const byte QoS2 = 0x2 << 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT CONNACK return codes.
|
||||
/// Mirrors the <c>mqttConnAckRC*</c> constants in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal static class MqttConnAckRc
|
||||
{
|
||||
public const byte Accepted = 0x00;
|
||||
public const byte UnacceptableProtocol = 0x01;
|
||||
public const byte IdentifierRejected = 0x02;
|
||||
public const byte ServerUnavailable = 0x03;
|
||||
public const byte BadUserOrPassword = 0x04;
|
||||
public const byte NotAuthorized = 0x05;
|
||||
public const byte QoS2WillRejected = 0x10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Miscellaneous MQTT protocol constants.
|
||||
/// Mirrors the remaining scalar constants in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal static class MqttConst
|
||||
{
|
||||
/// <summary>Maximum control packet payload size (0xFFFFFFF).</summary>
|
||||
public const int MaxPayloadSize = 0xFFFFFFF;
|
||||
|
||||
/// <summary>MQTT topic level separator character ('/').</summary>
|
||||
public const char TopicLevelSep = '/';
|
||||
|
||||
/// <summary>Single-level wildcard character ('+').</summary>
|
||||
public const char SingleLevelWildcard = '+';
|
||||
|
||||
/// <summary>Multi-level wildcard character ('#').</summary>
|
||||
public const char MultiLevelWildcard = '#';
|
||||
|
||||
/// <summary>Reserved topic prefix character ('$').</summary>
|
||||
public const char ReservedPrefix = '$';
|
||||
|
||||
/// <summary>MQTT protocol level byte (v3.1.1 = 0x04).</summary>
|
||||
public const byte ProtoLevel = 0x04;
|
||||
|
||||
/// <summary>SUBACK failure return code (0x80).</summary>
|
||||
public const byte SubAckFailure = 0x80;
|
||||
|
||||
/// <summary>Fixed flags byte in SUBSCRIBE packets (0x02).</summary>
|
||||
public const byte SubscribeFlags = 0x02;
|
||||
|
||||
/// <summary>Fixed flags byte in UNSUBSCRIBE packets (0x02).</summary>
|
||||
public const byte UnsubscribeFlags = 0x02;
|
||||
|
||||
/// <summary>
|
||||
/// Suffix appended to the SID of subscriptions created for MQTT '#' wildcard
|
||||
/// at the upper level. Mirrors <c>mqttMultiLevelSidSuffix</c>.
|
||||
/// </summary>
|
||||
public const string MultiLevelSidSuffix = " fwc";
|
||||
|
||||
/// <summary>Initial byte allocation for publish headers (overestimate).</summary>
|
||||
public const int InitialPubHeader = 16;
|
||||
|
||||
/// <summary>Default maximum number of pending QoS-1 acks per session.</summary>
|
||||
public const int DefaultMaxAckPending = 1024;
|
||||
|
||||
/// <summary>Absolute upper limit on cumulative MaxAckPending across all session subscriptions.</summary>
|
||||
public const int MaxAckTotalLimit = 0xFFFF;
|
||||
|
||||
/// <summary>WebSocket path for MQTT connections.</summary>
|
||||
public const string WsPath = "/mqtt";
|
||||
|
||||
/// <summary>Marker character for deleted retained messages (used in flag field).</summary>
|
||||
public const char RetainedFlagDelMarker = '-';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT-internal NATS subject / stream / consumer name constants.
|
||||
/// Mirrors the string constants in server/mqtt.go that define JetStream stream names,
|
||||
/// subject prefixes, and JSA reply tokens.
|
||||
/// </summary>
|
||||
internal static class MqttTopics
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Top-level MQTT subject prefix
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Prefix used for all internal MQTT subjects.</summary>
|
||||
public const string Prefix = "$MQTT.";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix for NATS subscriptions used as JS consumer delivery subjects.
|
||||
/// MQTT clients must not subscribe to subjects starting with this prefix.
|
||||
/// </summary>
|
||||
public const string SubPrefix = Prefix + "sub.";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JetStream stream names
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Stream name for MQTT QoS >0 messages on a given account.</summary>
|
||||
public const string MsgsStreamName = "$MQTT_msgs";
|
||||
|
||||
/// <summary>Subject prefix for messages in the MQTT messages stream.</summary>
|
||||
public const string MsgsStreamSubjectPrefix = Prefix + "msgs.";
|
||||
|
||||
/// <summary>Stream name for MQTT retained messages.</summary>
|
||||
public const string RetainedMsgsStreamName = "$MQTT_rmsgs";
|
||||
|
||||
/// <summary>Subject prefix for messages in the retained messages stream.</summary>
|
||||
public const string RetainedMsgsStreamSubject = Prefix + "rmsgs.";
|
||||
|
||||
/// <summary>Stream name for MQTT session state.</summary>
|
||||
public const string SessStreamName = "$MQTT_sess";
|
||||
|
||||
/// <summary>Subject prefix for session state messages.</summary>
|
||||
public const string SessStreamSubjectPrefix = Prefix + "sess.";
|
||||
|
||||
/// <summary>Name prefix used when creating per-account session streams.</summary>
|
||||
public const string SessionsStreamNamePrefix = "$MQTT_sess_";
|
||||
|
||||
/// <summary>Stream name for incoming QoS-2 messages.</summary>
|
||||
public const string QoS2IncomingMsgsStreamName = "$MQTT_qos2in";
|
||||
|
||||
/// <summary>Subject prefix for incoming QoS-2 messages.</summary>
|
||||
public const string QoS2IncomingMsgsStreamSubjectPrefix = Prefix + "qos2.in.";
|
||||
|
||||
/// <summary>Stream name for outgoing MQTT QoS messages (PUBREL).</summary>
|
||||
public const string OutStreamName = "$MQTT_out";
|
||||
|
||||
/// <summary>Subject prefix for outgoing MQTT messages.</summary>
|
||||
public const string OutSubjectPrefix = Prefix + "out.";
|
||||
|
||||
/// <summary>Subject prefix for PUBREL messages.</summary>
|
||||
public const string PubRelSubjectPrefix = Prefix + "out.pubrel.";
|
||||
|
||||
/// <summary>Subject prefix for PUBREL delivery subjects.</summary>
|
||||
public const string PubRelDeliverySubjectPrefix = Prefix + "deliver.pubrel.";
|
||||
|
||||
/// <summary>Durable consumer name prefix for PUBREL.</summary>
|
||||
public const string PubRelConsumerDurablePrefix = "$MQTT_PUBREL_";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JSA reply subject prefix and token constants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Prefix of the reply subject for JS API requests.</summary>
|
||||
public const string JsaRepliesPrefix = Prefix + "JSA.";
|
||||
|
||||
// Token position indices within a JSA reply subject.
|
||||
public const int JsaIdTokenPos = 3;
|
||||
public const int JsaTokenPos = 4;
|
||||
public const int JsaClientIdPos = 5;
|
||||
|
||||
// JSA operation token values.
|
||||
public const string JsaStreamCreate = "SC";
|
||||
public const string JsaStreamUpdate = "SU";
|
||||
public const string JsaStreamLookup = "SL";
|
||||
public const string JsaStreamDel = "SD";
|
||||
public const string JsaConsumerCreate = "CC";
|
||||
public const string JsaConsumerLookup = "CL";
|
||||
public const string JsaConsumerDel = "CD";
|
||||
public const string JsaMsgStore = "MS";
|
||||
public const string JsaMsgLoad = "ML";
|
||||
public const string JsaMsgDelete = "MD";
|
||||
public const string JsaSessPersist = "SP";
|
||||
public const string JsaRetainedMsgDel = "RD";
|
||||
public const string JsaStreamNames = "SN";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NATS header names injected into re-encoded PUBLISH messages
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Header that indicates the message originated from MQTT and stores published QoS.</summary>
|
||||
public const string NatsHeader = "Nmqtt-Pub";
|
||||
|
||||
/// <summary>Header storing the original MQTT topic for retained messages.</summary>
|
||||
public const string NatsRetainedMessageTopic = "Nmqtt-RTopic";
|
||||
|
||||
/// <summary>Header storing the origin of a retained message.</summary>
|
||||
public const string NatsRetainedMessageOrigin = "Nmqtt-ROrigin";
|
||||
|
||||
/// <summary>Header storing the flags of a retained message.</summary>
|
||||
public const string NatsRetainedMessageFlags = "Nmqtt-RFlags";
|
||||
|
||||
/// <summary>Header storing the source of a retained message.</summary>
|
||||
public const string NatsRetainedMessageSource = "Nmqtt-RSource";
|
||||
|
||||
/// <summary>Header indicating a PUBREL message and storing the packet identifier.</summary>
|
||||
public const string NatsPubRelHeader = "Nmqtt-PubRel";
|
||||
|
||||
/// <summary>Header storing the original MQTT subject in re-encoded PUBLISH messages.</summary>
|
||||
public const string NatsHeaderSubject = "Nmqtt-Subject";
|
||||
|
||||
/// <summary>Header storing the subject mapping in re-encoded PUBLISH messages.</summary>
|
||||
public const string NatsHeaderMapped = "Nmqtt-Mapped";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sparkplug B constants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public const string SparkbNBirth = "NBIRTH";
|
||||
public const string SparkbDBirth = "DBIRTH";
|
||||
public const string SparkbNDeath = "NDEATH";
|
||||
public const string SparkbDDeath = "DDEATH";
|
||||
}
|
||||
252
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs
Normal file
252
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttHandler.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/mqtt.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Mqtt;
|
||||
|
||||
// ============================================================================
|
||||
// Per-client MQTT state
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Per-client MQTT state attached to every connection established via the MQTT
|
||||
/// listener or WebSocket upgrade.
|
||||
/// Mirrors Go <c>mqtt</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttHandler
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Identity
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>MQTT client identifier presented in the CONNECT packet.</summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this is a clean session.</summary>
|
||||
public bool CleanSession { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Session / Will
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Session associated with this connection after a successful CONNECT.</summary>
|
||||
public MqttSession? Session { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quick reference to the account session manager.
|
||||
/// Immutable after <c>processConnect()</c> completes.
|
||||
/// </summary>
|
||||
public MqttAccountSessionManager? AccountSessionManager { get; set; }
|
||||
|
||||
/// <summary>Will message to publish when this connection closes unexpectedly.</summary>
|
||||
public MqttWill? Will { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Keep-alive
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Keep-alive interval in seconds (0 = disabled).</summary>
|
||||
public ushort KeepAlive { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// QoS pending / packet identifiers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Next packet identifier to use for QoS >0 outbound messages.</summary>
|
||||
public ushort NextPi { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pending ack map: packet identifier → pending state.
|
||||
/// Used for tracking in-flight QoS 1/2 PUBLISH packets.
|
||||
/// </summary>
|
||||
public Dictionary<ushort, MqttPending?> Pending { get; } = new();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Protocol flags
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the server rejects QoS-2 PUBLISH from this client
|
||||
/// and terminates the connection on receipt of such a packet.
|
||||
/// Mirrors Go <c>mqtt.rejectQoS2Pub</c>.
|
||||
/// </summary>
|
||||
public bool RejectQoS2Pub { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, QoS-2 SUBSCRIBE requests are silently downgraded to QoS-1.
|
||||
/// Mirrors Go <c>mqtt.downgradeQoS2Sub</c>.
|
||||
/// </summary>
|
||||
public bool DowngradeQoS2Sub { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Parse state (used by the read-loop MQTT byte-stream parser)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Current state of the fixed-header / remaining-length state machine.</summary>
|
||||
public byte ParseState { get; set; }
|
||||
|
||||
/// <summary>Control packet type byte extracted from the current fixed header.</summary>
|
||||
public byte PktType { get; set; }
|
||||
|
||||
/// <summary>Remaining length of the current control packet (bytes still to read).</summary>
|
||||
public int RemLen { get; set; }
|
||||
|
||||
/// <summary>Buffer accumulating the current packet's variable-header and payload.</summary>
|
||||
public byte[]? Buf { get; set; }
|
||||
|
||||
/// <summary>Multiplier accumulator used during multi-byte remaining-length decoding.</summary>
|
||||
public int RemLenMult { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Thread safety
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Lock protecting mutable fields on this instance.</summary>
|
||||
public Lock Mu => _mu;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Server-side MQTT extension methods (stubs)
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Stub extension methods on <see cref="NatsServer"/> for MQTT server operations.
|
||||
/// Mirrors the server-receiver MQTT functions in server/mqtt.go.
|
||||
/// All methods throw <see cref="NotImplementedException"/> until session 22 is complete.
|
||||
/// </summary>
|
||||
internal static class MqttServerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Start listening for MQTT client connections.
|
||||
/// Mirrors Go <c>(*Server).startMQTT()</c>.
|
||||
/// </summary>
|
||||
public static void StartMqtt(this NatsServer server) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Configure MQTT authentication overrides from the MQTT options block.
|
||||
/// Mirrors Go <c>(*Server).mqttConfigAuth()</c>.
|
||||
/// </summary>
|
||||
public static void MqttConfigAuth(this NatsServer server, object mqttOpts) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Handle cleanup when an MQTT client connection closes.
|
||||
/// Mirrors Go <c>(*Server).mqttHandleClosedClient()</c>.
|
||||
/// </summary>
|
||||
public static void MqttHandleClosedClient(this NatsServer server, object client) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Propagate a change to the maximum ack-pending limit to all MQTT sessions.
|
||||
/// Mirrors Go <c>(*Server).mqttUpdateMaxAckPending()</c>.
|
||||
/// </summary>
|
||||
public static void MqttUpdateMaxAckPending(this NatsServer server, ushort maxp) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve or lazily-create the JSA for the named account.
|
||||
/// Mirrors Go <c>(*Server).mqttGetJSAForAccount()</c>.
|
||||
/// </summary>
|
||||
public static MqttJsa MqttGetJsaForAccount(this NatsServer server, string account) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Store a QoS message for an account on a (possibly new) NATS subject.
|
||||
/// Mirrors Go <c>(*Server).mqttStoreQoSMsgForAccountOnNewSubject()</c>.
|
||||
/// </summary>
|
||||
public static void MqttStoreQosMsgForAccountOnNewSubject(
|
||||
this NatsServer server,
|
||||
int hdr, byte[] msg, string account, string subject) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Get or create the <see cref="MqttAccountSessionManager"/> for the client's account.
|
||||
/// Mirrors Go <c>(*Server).getOrCreateMQTTAccountSessionManager()</c>.
|
||||
/// </summary>
|
||||
public static MqttAccountSessionManager GetOrCreateMqttAccountSessionManager(
|
||||
this NatsServer server, object client) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="MqttAccountSessionManager"/> for the given account.
|
||||
/// Mirrors Go <c>(*Server).mqttCreateAccountSessionManager()</c>.
|
||||
/// </summary>
|
||||
public static MqttAccountSessionManager MqttCreateAccountSessionManager(
|
||||
this NatsServer server, object account, System.Threading.CancellationToken cancel) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Determine how many JetStream replicas to use for MQTT streams.
|
||||
/// Mirrors Go <c>(*Server).mqttDetermineReplicas()</c>.
|
||||
/// </summary>
|
||||
public static int MqttDetermineReplicas(this NatsServer server) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Process an MQTT CONNECT packet after parsing.
|
||||
/// Mirrors Go <c>(*Server).mqttProcessConnect()</c>.
|
||||
/// </summary>
|
||||
public static void MqttProcessConnect(
|
||||
this NatsServer server, object client, MqttConnectProto cp, bool trace) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Send the Will message for a client that disconnected unexpectedly.
|
||||
/// Mirrors Go <c>(*Server).mqttHandleWill()</c>.
|
||||
/// </summary>
|
||||
public static void MqttHandleWill(this NatsServer server, object client) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Process an inbound MQTT PUBLISH packet.
|
||||
/// Mirrors Go <c>(*Server).mqttProcessPub()</c>.
|
||||
/// </summary>
|
||||
public static void MqttProcessPub(
|
||||
this NatsServer server, object client, MqttPublishInfo pp, bool trace) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Initiate delivery of a PUBLISH message via JetStream.
|
||||
/// Mirrors Go <c>(*Server).mqttInitiateMsgDelivery()</c>.
|
||||
/// </summary>
|
||||
public static void MqttInitiateMsgDelivery(
|
||||
this NatsServer server, object client, MqttPublishInfo pp) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Store a QoS-2 PUBLISH exactly once (idempotent).
|
||||
/// Mirrors Go <c>(*Server).mqttStoreQoS2MsgOnce()</c>.
|
||||
/// </summary>
|
||||
public static void MqttStoreQoS2MsgOnce(
|
||||
this NatsServer server, object client, MqttPublishInfo pp) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Process an inbound MQTT PUBREL packet.
|
||||
/// Mirrors Go <c>(*Server).mqttProcessPubRel()</c>.
|
||||
/// </summary>
|
||||
public static void MqttProcessPubRel(
|
||||
this NatsServer server, object client, ushort pi, bool trace) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
/// <summary>
|
||||
/// Audit retained-message permissions after a configuration reload.
|
||||
/// Mirrors Go <c>(*Server).mqttCheckPubRetainedPerms()</c>.
|
||||
/// </summary>
|
||||
public static void MqttCheckPubRetainedPerms(this NatsServer server) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
}
|
||||
391
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs
Normal file
391
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttTypes.cs
Normal file
@@ -0,0 +1,391 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/mqtt.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Mqtt;
|
||||
|
||||
// ============================================================================
|
||||
// Enumerations
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// State machine states for parsing incoming MQTT byte streams.
|
||||
/// Mirrors the <c>mqttParseState*</c> iota in server/mqtt.go (implicit from
|
||||
/// the read-loop logic).
|
||||
/// </summary>
|
||||
internal enum MqttParseState : byte
|
||||
{
|
||||
/// <summary>Waiting for the first fixed-header byte.</summary>
|
||||
MqttStateHeader = 0,
|
||||
|
||||
/// <summary>Reading the remaining-length variable-integer bytes.</summary>
|
||||
MqttStateFixedHeader,
|
||||
|
||||
/// <summary>Reading the variable-header + payload bytes of the current packet.</summary>
|
||||
MqttStateControlPacket,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Will
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// MQTT Will message parameters extracted from a CONNECT packet.
|
||||
/// Mirrors Go <c>mqttWill</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttWill
|
||||
{
|
||||
/// <summary>NATS subject derived from the MQTT will topic.</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Original MQTT will topic string.</summary>
|
||||
public string Topic { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Will message payload bytes, or <c>null</c> if empty.</summary>
|
||||
public byte[]? Msg { get; set; }
|
||||
|
||||
/// <summary>QoS level for the will message (0, 1, or 2).</summary>
|
||||
public byte Qos { get; set; }
|
||||
|
||||
/// <summary>Whether the will message should be retained.</summary>
|
||||
public bool Retain { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connect protocol
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// MQTT CONNECT packet parsed payload.
|
||||
/// Mirrors Go <c>mqttConnectProto</c> struct in server/mqtt.go (extended with
|
||||
/// the fields surfaced by the parse helpers).
|
||||
/// </summary>
|
||||
internal sealed class MqttConnectProto
|
||||
{
|
||||
/// <summary>MQTT client identifier.</summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Raw CONNECT packet bytes (for forwarding / replay).</summary>
|
||||
public byte[] Connect { get; set; } = [];
|
||||
|
||||
/// <summary>Parsed Will parameters, or <c>null</c> if the Will flag is not set.</summary>
|
||||
public MqttWill? Will { get; set; }
|
||||
|
||||
/// <summary>Username presented in the CONNECT packet.</summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Password bytes presented in the CONNECT packet, or <c>null</c> if absent.</summary>
|
||||
public byte[]? Password { get; set; }
|
||||
|
||||
/// <summary>Whether the Clean Session flag was set.</summary>
|
||||
public bool CleanSession { get; set; }
|
||||
|
||||
/// <summary>Keep-alive interval in seconds (0 = disabled).</summary>
|
||||
public ushort KeepAlive { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subscription
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A single MQTT topic filter subscription entry stored in a session.
|
||||
/// Mirrors the per-entry semantics of <c>mqttSession.subs</c> map in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttSubscription
|
||||
{
|
||||
/// <summary>NATS subject derived from the MQTT topic filter.</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Maximum QoS level granted for this subscription.</summary>
|
||||
public byte Qos { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Publish info
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Parsed metadata for an inbound MQTT PUBLISH packet.
|
||||
/// Mirrors Go <c>mqttPublish</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttPublishInfo
|
||||
{
|
||||
/// <summary>NATS subject derived from the MQTT topic.</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Original MQTT topic string.</summary>
|
||||
public string Topic { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Message payload bytes, or <c>null</c> if empty.</summary>
|
||||
public byte[]? Msg { get; set; }
|
||||
|
||||
/// <summary>QoS level of the PUBLISH packet.</summary>
|
||||
public byte Qos { get; set; }
|
||||
|
||||
/// <summary>Whether the Retain flag is set.</summary>
|
||||
public bool Retain { get; set; }
|
||||
|
||||
/// <summary>Whether the DUP flag is set (re-delivery of a QoS >0 packet).</summary>
|
||||
public bool Dup { get; set; }
|
||||
|
||||
/// <summary>Packet identifier (only meaningful for QoS 1 and 2).</summary>
|
||||
public ushort Pi { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pending ack
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a single in-flight QoS 1 or QoS 2 message pending acknowledgement.
|
||||
/// Mirrors Go <c>mqttPending</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttPending
|
||||
{
|
||||
/// <summary>JetStream stream sequence number for this message.</summary>
|
||||
public ulong SSeq { get; set; }
|
||||
|
||||
/// <summary>JetStream ACK subject to send the acknowledgement to.</summary>
|
||||
public string JsAckSubject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JetStream durable consumer name.</summary>
|
||||
public string JsDur { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Retained message
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A retained MQTT message stored in JetStream.
|
||||
/// Mirrors Go <c>mqttRetainedMsg</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttRetainedMsg
|
||||
{
|
||||
/// <summary>Origin server name.</summary>
|
||||
public string Origin { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>NATS subject for this retained message.</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Original MQTT topic.</summary>
|
||||
public string Topic { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Message payload bytes.</summary>
|
||||
public byte[]? Msg { get; set; }
|
||||
|
||||
/// <summary>Message flags byte.</summary>
|
||||
public byte Flags { get; set; }
|
||||
|
||||
/// <summary>Source identifier.</summary>
|
||||
public string Source { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Persisted session
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The JSON-serialisable representation of an MQTT session stored in JetStream.
|
||||
/// Mirrors Go <c>mqttPersistedSession</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttPersistedSession
|
||||
{
|
||||
/// <summary>Server that originally created this session.</summary>
|
||||
public string Origin { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>MQTT client identifier.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this was a clean session.</summary>
|
||||
public bool Clean { get; set; }
|
||||
|
||||
/// <summary>Map of MQTT topic filters to granted QoS levels.</summary>
|
||||
public Dictionary<string, byte> Subs { get; set; } = new();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// In-memory MQTT session state.
|
||||
/// Mirrors Go <c>mqttSession</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttSession
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
|
||||
/// <summary>Lock for this session (matches Go <c>sess.mu</c>).</summary>
|
||||
public Lock Mu => _mu;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Identity
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>MQTT client identifier.</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hash of the client identifier (used as JetStream key).</summary>
|
||||
public string IdHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this is a clean session.</summary>
|
||||
public bool Clean { get; set; }
|
||||
|
||||
/// <summary>Domain token (domain with trailing '.', or empty).</summary>
|
||||
public string DomainTk { get; set; } = string.Empty;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Subscriptions
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Map from MQTT SUBSCRIBE filter to granted QoS level.
|
||||
/// Mirrors Go <c>mqttSession.subs map[string]byte</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, byte> Subs { get; } = new();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pending acks
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Maximum number of in-flight QoS-1/2 PUBLISH acks.</summary>
|
||||
public ushort MaxPending { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// In-flight QoS-1 PUBLISH packets pending PUBACK from the client.
|
||||
/// Key is the packet identifier.
|
||||
/// </summary>
|
||||
public Dictionary<ushort, MqttPending> PendingPublish { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// In-flight QoS-2 PUBREL packets pending PUBCOMP from the client.
|
||||
/// Key is the packet identifier.
|
||||
/// </summary>
|
||||
public Dictionary<ushort, MqttPending> PendingPubRel { get; } = new();
|
||||
|
||||
/// <summary>"Last used" packet identifier; used as the starting point when allocating the next one.</summary>
|
||||
public ushort LastPi { get; set; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Constructor
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Initialises a new session with the given identity.</summary>
|
||||
public MqttSession(string id, string idHash, bool clean)
|
||||
{
|
||||
Id = id;
|
||||
IdHash = idHash;
|
||||
Clean = clean;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSA stub
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Stub for the MQTT JetStream API helper.
|
||||
/// Mirrors Go <c>mqttJSA</c> struct in server/mqtt.go.
|
||||
/// All methods throw <see cref="NotImplementedException"/> until session 22 is complete.
|
||||
/// </summary>
|
||||
internal sealed class MqttJsa
|
||||
{
|
||||
/// <summary>Domain (with trailing '.'), or empty.</summary>
|
||||
public string Domain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the domain field was explicitly set (even to empty).</summary>
|
||||
public bool DomainSet { get; set; }
|
||||
|
||||
// All methods are stubs — full implementation is deferred to session 22.
|
||||
public void SendAck(string ackSubject) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
public void SendMsg(string subject, byte[] msg) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
public void StoreMsgNoWait(string subject, int hdrLen, byte[] msg) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
public string PrefixDomain(string subject) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account session manager stub
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Per-account MQTT session manager.
|
||||
/// Mirrors Go <c>mqttAccountSessionManager</c> struct in server/mqtt.go.
|
||||
/// All mutating methods are stubs.
|
||||
/// </summary>
|
||||
internal sealed class MqttAccountSessionManager
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
|
||||
/// <summary>Domain token (domain with trailing '.'), or empty.</summary>
|
||||
public string DomainTk { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Active sessions keyed by MQTT client ID.</summary>
|
||||
public Dictionary<string, MqttSession> Sessions { get; } = new();
|
||||
|
||||
/// <summary>Sessions keyed by their client ID hash.</summary>
|
||||
public Dictionary<string, MqttSession> SessionsByHash { get; } = new();
|
||||
|
||||
/// <summary>Client IDs that are currently locked (being taken over).</summary>
|
||||
public HashSet<string> SessionsLocked { get; } = new();
|
||||
|
||||
/// <summary>Client IDs that have recently flapped (connected with duplicate ID).</summary>
|
||||
public Dictionary<string, long> Flappers { get; } = new();
|
||||
|
||||
/// <summary>JSA helper for this account.</summary>
|
||||
public MqttJsa Jsa { get; } = new();
|
||||
|
||||
/// <summary>Lock for this manager.</summary>
|
||||
public Lock Mu => _mu;
|
||||
|
||||
// All methods are stubs.
|
||||
public void HandleClosedClient(string clientId) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
public MqttSession? LookupSession(string clientId) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
public void PersistSession(MqttSession session) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
|
||||
public void DeleteSession(MqttSession session) =>
|
||||
throw new NotImplementedException("TODO: session 22");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global session manager stub
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Server-wide MQTT session manager.
|
||||
/// Mirrors Go <c>mqttSessionManager</c> struct in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal sealed class MqttSessionManager
|
||||
{
|
||||
private readonly Lock _mu = new();
|
||||
|
||||
/// <summary>Per-account session managers keyed by account name.</summary>
|
||||
public Dictionary<string, MqttAccountSessionManager> Sessions { get; } = new();
|
||||
|
||||
/// <summary>Lock for this manager.</summary>
|
||||
public Lock Mu => _mu;
|
||||
}
|
||||
@@ -38,6 +38,32 @@ public static class NatsHeaderConstants
|
||||
// Other commonly used headers.
|
||||
public const string JsMsgId = "Nats-Msg-Id";
|
||||
public const string JsMsgRollup = "Nats-Rollup";
|
||||
public const string JsMsgSize = "Nats-Msg-Size";
|
||||
public const string JsResponseType = "Nats-Response-Type";
|
||||
public const string JsMessageTtl = "Nats-TTL";
|
||||
public const string JsMarkerReason = "Nats-Marker-Reason";
|
||||
public const string JsMessageIncr = "Nats-Incr";
|
||||
public const string JsBatchId = "Nats-Batch-Id";
|
||||
public const string JsBatchSeq = "Nats-Batch-Sequence";
|
||||
public const string JsBatchCommit = "Nats-Batch-Commit";
|
||||
|
||||
// Scheduling headers.
|
||||
public const string JsSchedulePattern = "Nats-Schedule";
|
||||
public const string JsScheduleTtl = "Nats-Schedule-TTL";
|
||||
public const string JsScheduleTarget = "Nats-Schedule-Target";
|
||||
public const string JsScheduleSource = "Nats-Schedule-Source";
|
||||
public const string JsScheduler = "Nats-Scheduler";
|
||||
public const string JsScheduleNext = "Nats-Schedule-Next";
|
||||
public const string JsScheduleNextPurge = "purge";
|
||||
|
||||
// Rollup values.
|
||||
public const string JsMsgRollupSubject = "sub";
|
||||
public const string JsMsgRollupAll = "all";
|
||||
|
||||
// Marker reasons.
|
||||
public const string JsMarkerReasonMaxAge = "MaxAge";
|
||||
public const string JsMarkerReasonPurge = "Purge";
|
||||
public const string JsMarkerReasonRemove = "Remove";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
732
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs
Normal file
732
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs
Normal file
@@ -0,0 +1,732 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/server.go (account methods) in the NATS server Go source.
|
||||
// Session 09: account management — configure, register, lookup, fetch.
|
||||
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
public sealed partial class NatsServer
|
||||
{
|
||||
// =========================================================================
|
||||
// Account-mode helpers (features 3004–3007)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when only the global ($G) account is defined (pre-NATS 2.0 mode).
|
||||
/// Mirrors Go <c>Server.globalAccountOnly</c>.
|
||||
/// </summary>
|
||||
public bool GlobalAccountOnly()
|
||||
{
|
||||
if (_trustedKeys is not null) return false;
|
||||
|
||||
bool hasOthers = false;
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
foreach (var kvp in _accounts)
|
||||
{
|
||||
var acc = kvp.Value;
|
||||
// Ignore global and system accounts.
|
||||
if (acc == _gacc) continue;
|
||||
var sysAcc = _sysAccAtomic;
|
||||
if (sysAcc != null && acc == sysAcc) continue;
|
||||
hasOthers = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally { _mu.ExitReadLock(); }
|
||||
|
||||
return !hasOthers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when this server has no routes or gateways configured.
|
||||
/// Mirrors Go <c>Server.standAloneMode</c>.
|
||||
/// </summary>
|
||||
public bool StandAloneMode()
|
||||
{
|
||||
var opts = GetOpts();
|
||||
return opts.Cluster.Port == 0 && opts.Gateway.Port == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of configured routes.
|
||||
/// Mirrors Go <c>Server.configuredRoutes</c>.
|
||||
/// </summary>
|
||||
public int ConfiguredRoutes() => GetOpts().Routes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns online JetStream peer node names from the node-info map.
|
||||
/// Mirrors Go <c>Server.ActivePeers</c>.
|
||||
/// </summary>
|
||||
public List<string> ActivePeers()
|
||||
{
|
||||
var peers = new List<string>();
|
||||
foreach (var kvp in _nodeToInfo)
|
||||
{
|
||||
if (kvp.Value is NodeInfo ni && !ni.Offline)
|
||||
peers.Add(kvp.Key);
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ConfigureAccounts (feature 3001)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Reads accounts from options and registers/updates them.
|
||||
/// Returns a set of account names whose stream imports changed (for reload)
|
||||
/// and any error.
|
||||
/// Mirrors Go <c>Server.configureAccounts</c>.
|
||||
/// Server lock must be held on entry.
|
||||
/// </summary>
|
||||
public (HashSet<string> ChangedStreamImports, Exception? Error) ConfigureAccounts(bool reloading)
|
||||
{
|
||||
var awcsti = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Create the global ($G) account if not yet present.
|
||||
if (_gacc == null)
|
||||
{
|
||||
_gacc = new Account { Name = ServerConstants.DefaultGlobalAccount };
|
||||
RegisterAccountNoLock(_gacc);
|
||||
}
|
||||
|
||||
var opts = GetOpts();
|
||||
|
||||
// Walk accounts from options.
|
||||
foreach (var optAcc in opts.Accounts)
|
||||
{
|
||||
Account a;
|
||||
bool create = true;
|
||||
|
||||
if (reloading && optAcc.Name != ServerConstants.DefaultGlobalAccount)
|
||||
{
|
||||
if (_accounts.TryGetValue(optAcc.Name, out var existing))
|
||||
{
|
||||
a = existing;
|
||||
// Full import/export diffing deferred to session 11 (accounts.go).
|
||||
create = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
a = new Account { Name = optAcc.Name };
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
a = optAcc.Name == ServerConstants.DefaultGlobalAccount ? _gacc! : new Account { Name = optAcc.Name };
|
||||
}
|
||||
|
||||
if (create)
|
||||
{
|
||||
// Will be a no-op for the global account (already registered).
|
||||
RegisterAccountNoLock(a);
|
||||
}
|
||||
|
||||
// If an account named $SYS is found, make it the system account.
|
||||
if (optAcc.Name == ServerConstants.DefaultSystemAccount &&
|
||||
string.IsNullOrEmpty(opts.SystemAccount))
|
||||
{
|
||||
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve system account if configured.
|
||||
if (!string.IsNullOrEmpty(opts.SystemAccount))
|
||||
{
|
||||
// Release server lock for lookupAccount (lock ordering: account → server).
|
||||
_mu.ExitWriteLock();
|
||||
var (acc, err) = LookupAccountInternal(opts.SystemAccount);
|
||||
_mu.EnterWriteLock();
|
||||
|
||||
if (err != null)
|
||||
return (awcsti, new InvalidOperationException($"error resolving system account: {err.Message}", err));
|
||||
|
||||
if (acc != null && _sys != null && acc != _sys.Account)
|
||||
_sys.Account = acc;
|
||||
|
||||
if (acc != null)
|
||||
_sysAccAtomic = acc;
|
||||
}
|
||||
|
||||
return (awcsti, null);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Account counts (features 3022–3023)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of registered accounts (slow, test only).
|
||||
/// Mirrors Go <c>Server.numAccounts</c>.
|
||||
/// </summary>
|
||||
public int NumAccounts()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _accounts.Count; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of loaded accounts.
|
||||
/// Mirrors Go <c>Server.NumLoadedAccounts</c>.
|
||||
/// </summary>
|
||||
public int NumLoadedAccounts() => NumAccounts();
|
||||
|
||||
// =========================================================================
|
||||
// Account registration (features 3024–3025)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns the named account if known, or creates and registers a new one.
|
||||
/// Mirrors Go <c>Server.LookupOrRegisterAccount</c>.
|
||||
/// </summary>
|
||||
public (Account Account, bool IsNew) LookupOrRegisterAccount(string name)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_accounts.TryGetValue(name, out var existing))
|
||||
return (existing, false);
|
||||
|
||||
var acc = new Account { Name = name };
|
||||
RegisterAccountNoLock(acc);
|
||||
return (acc, true);
|
||||
}
|
||||
finally { _mu.ExitWriteLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new account. Returns error if the account already exists.
|
||||
/// Mirrors Go <c>Server.RegisterAccount</c>.
|
||||
/// </summary>
|
||||
public (Account? Account, Exception? Error) RegisterAccount(string name)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_accounts.ContainsKey(name))
|
||||
return (null, ServerErrors.ErrAccountExists);
|
||||
|
||||
var acc = new Account { Name = name };
|
||||
RegisterAccountNoLock(acc);
|
||||
return (acc, null);
|
||||
}
|
||||
finally { _mu.ExitWriteLock(); }
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// System account (features 3026–3030)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Sets the named account as the server's system account.
|
||||
/// Mirrors Go <c>Server.SetSystemAccount</c>.
|
||||
/// </summary>
|
||||
public Exception? SetSystemAccount(string accName)
|
||||
{
|
||||
if (_accounts.TryGetValue(accName, out var acc))
|
||||
return SetSystemAccountInternal(acc);
|
||||
|
||||
// Not locally known — try resolver.
|
||||
var (ac, _, fetchErr) = FetchAccountClaims(accName);
|
||||
if (fetchErr != null) return fetchErr;
|
||||
|
||||
var newAcc = BuildInternalAccount(ac);
|
||||
// Due to race, registerAccount returns the existing one if already registered.
|
||||
var racc = RegisterAccountInternal(newAcc);
|
||||
return SetSystemAccountInternal(racc ?? newAcc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the system account, or null if none is set.
|
||||
/// Mirrors Go <c>Server.SystemAccount</c>.
|
||||
/// </summary>
|
||||
public Account? SystemAccount() => _sysAccAtomic;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the global ($G) account.
|
||||
/// Mirrors Go <c>Server.GlobalAccount</c>.
|
||||
/// </summary>
|
||||
public Account? GlobalAccount()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _gacc; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default system account ($SYS) if one does not already exist.
|
||||
/// Mirrors Go <c>Server.SetDefaultSystemAccount</c>.
|
||||
/// </summary>
|
||||
public Exception? SetDefaultSystemAccount()
|
||||
{
|
||||
var (_, isNew) = LookupOrRegisterAccount(ServerConstants.DefaultSystemAccount);
|
||||
if (!isNew) return null;
|
||||
Debugf("Created system account: \"{0}\"", ServerConstants.DefaultSystemAccount);
|
||||
return SetSystemAccount(ServerConstants.DefaultSystemAccount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns <paramref name="acc"/> as the system account and starts internal
|
||||
/// messaging goroutines.
|
||||
/// Mirrors Go <c>Server.setSystemAccount</c>.
|
||||
/// Server lock must NOT be held on entry; this method acquires/releases it.
|
||||
/// </summary>
|
||||
public Exception? SetSystemAccountInternal(Account acc)
|
||||
{
|
||||
if (acc == null)
|
||||
return ServerErrors.ErrMissingAccount;
|
||||
if (acc.IsExpired())
|
||||
return ServerErrors.ErrAccountExpired;
|
||||
if (!IsTrustedIssuer(acc.Issuer))
|
||||
return ServerErrors.ErrAccountValidation;
|
||||
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_sys != null)
|
||||
return ServerErrors.ErrAccountExists;
|
||||
|
||||
_sys = new InternalState { Account = acc };
|
||||
}
|
||||
finally { _mu.ExitWriteLock(); }
|
||||
|
||||
// Store atomically for fast lookup on hot paths.
|
||||
_sysAccAtomic = acc;
|
||||
|
||||
// Full internal-messaging bootstrap (initEventTracking, sendLoop, etc.)
|
||||
// is deferred to session 12 (events.go).
|
||||
AddSystemAccountExports(acc);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal client factories (features 3031–3034)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Creates an internal system client.</summary>
|
||||
public ClientConnection CreateInternalSystemClient() =>
|
||||
CreateInternalClient(ClientKind.System);
|
||||
|
||||
/// <summary>Creates an internal JetStream client.</summary>
|
||||
public ClientConnection CreateInternalJetStreamClient() =>
|
||||
CreateInternalClient(ClientKind.JetStream);
|
||||
|
||||
/// <summary>Creates an internal account client.</summary>
|
||||
public ClientConnection CreateInternalAccountClient() =>
|
||||
CreateInternalClient(ClientKind.Account);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an internal client of the given <paramref name="kind"/>.
|
||||
/// Mirrors Go <c>Server.createInternalClient</c>.
|
||||
/// </summary>
|
||||
public ClientConnection CreateInternalClient(ClientKind kind)
|
||||
{
|
||||
if (kind != ClientKind.System && kind != ClientKind.JetStream && kind != ClientKind.Account)
|
||||
throw new InvalidOperationException($"createInternalClient: unsupported kind {kind}");
|
||||
|
||||
var c = new ClientConnection(kind, this);
|
||||
// Mirrors: c.echo = false; c.headers = true; flags.set(noReconnect)
|
||||
// Full client initialisation deferred to session 10 (client.go).
|
||||
return c;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Subscription tracking / account sublist (features 3035–3038)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if accounts should track subscriptions for route/gateway propagation.
|
||||
/// Server lock must be held on entry.
|
||||
/// Mirrors Go <c>Server.shouldTrackSubscriptions</c>.
|
||||
/// </summary>
|
||||
public bool ShouldTrackSubscriptions()
|
||||
{
|
||||
var opts = GetOpts();
|
||||
return opts.Cluster.Port != 0 || opts.Gateway.Port != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <see cref="RegisterAccountNoLock"/> under the server write lock.
|
||||
/// Returns the already-registered account if a duplicate is detected, or null.
|
||||
/// Mirrors Go <c>Server.registerAccount</c>.
|
||||
/// </summary>
|
||||
public Account? RegisterAccountInternal(Account acc)
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
try { return RegisterAccountNoLock(acc); }
|
||||
finally { _mu.ExitWriteLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the account's subscription index based on the NoSublistCache option.
|
||||
/// Mirrors Go <c>Server.setAccountSublist</c>.
|
||||
/// Server lock must be held on entry.
|
||||
/// </summary>
|
||||
public void SetAccountSublist(Account acc)
|
||||
{
|
||||
if (acc?.Sublist != null) return;
|
||||
if (acc == null) return;
|
||||
var opts = GetOpts();
|
||||
acc.Sublist = opts?.NoSublistCache == true
|
||||
? SubscriptionIndex.NewSublist(false)
|
||||
: SubscriptionIndex.NewSublistWithCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an account in the server's account map.
|
||||
/// If the account is already registered (race), returns the existing one.
|
||||
/// Server lock must be held on entry.
|
||||
/// Mirrors Go <c>Server.registerAccountNoLock</c>.
|
||||
/// </summary>
|
||||
public Account? RegisterAccountNoLock(Account acc)
|
||||
{
|
||||
// If already registered, return existing.
|
||||
if (_accounts.TryGetValue(acc.Name, out var existing))
|
||||
{
|
||||
_tmpAccounts.TryRemove(acc.Name, out _);
|
||||
return existing;
|
||||
}
|
||||
|
||||
SetAccountSublist(acc);
|
||||
SetRouteInfo(acc);
|
||||
acc.Server = this;
|
||||
acc.Updated = DateTime.UtcNow;
|
||||
|
||||
_accounts[acc.Name] = acc;
|
||||
_tmpAccounts.TryRemove(acc.Name, out _);
|
||||
|
||||
// enableAccountTracking and registerSystemImports deferred to session 12.
|
||||
EnableAccountTracking(acc);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route info for accounts (feature 3039)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Sets the account's route-pool index based on cluster configuration.
|
||||
/// Mirrors Go <c>Server.setRouteInfo</c>.
|
||||
/// Both server and account locks must be held on entry.
|
||||
/// </summary>
|
||||
public void SetRouteInfo(Account acc)
|
||||
{
|
||||
const int accDedicatedRoute = -1;
|
||||
|
||||
if (_accRoutes != null && _accRoutes.ContainsKey(acc.Name))
|
||||
{
|
||||
// Dedicated route: store name in hash map; use index -1.
|
||||
_accRouteByHash.TryAdd(acc.Name, null);
|
||||
acc.RoutePoolIdx = accDedicatedRoute;
|
||||
}
|
||||
else
|
||||
{
|
||||
acc.RoutePoolIdx = ComputeRoutePoolIdx(_routesPoolSize, acc.Name);
|
||||
if (_routesPoolSize > 1)
|
||||
_accRouteByHash.TryAdd(acc.Name, acc.RoutePoolIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Account lookup (features 3040–3042)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns the account for <paramref name="name"/> if locally known, without
|
||||
/// fetching from the resolver.
|
||||
/// Mirrors Go <c>Server.lookupAccountInternal</c> (private helper).
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public (Account? Account, Exception? Error) LookupAccountInternal(string name)
|
||||
=> LookupOrFetchAccount(name, fetch: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the account for <paramref name="name"/>, optionally fetching from
|
||||
/// the resolver if not locally known or if expired.
|
||||
/// Mirrors Go <c>Server.lookupOrFetchAccount</c>.
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public (Account? Account, Exception? Error) LookupOrFetchAccount(string name, bool fetch)
|
||||
{
|
||||
_accounts.TryGetValue(name, out var acc);
|
||||
|
||||
if (acc != null)
|
||||
{
|
||||
if (acc.IsExpired())
|
||||
{
|
||||
Debugf("Requested account [{0}] has expired", name);
|
||||
if (_accResolver != null && fetch)
|
||||
{
|
||||
var updateErr = UpdateAccount(acc);
|
||||
if (updateErr != null)
|
||||
return (null, ServerErrors.ErrAccountExpired);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (null, ServerErrors.ErrAccountExpired);
|
||||
}
|
||||
}
|
||||
return (acc, null);
|
||||
}
|
||||
|
||||
if (_accResolver == null || !fetch)
|
||||
return (null, ServerErrors.ErrMissingAccount);
|
||||
|
||||
return FetchAccountFromResolver(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public account lookup — always fetches from resolver if needed.
|
||||
/// Mirrors Go <c>Server.LookupAccount</c>.
|
||||
/// </summary>
|
||||
public (Account? Account, Exception? Error) LookupAccount(string name)
|
||||
=> LookupOrFetchAccount(name, fetch: true);
|
||||
|
||||
// =========================================================================
|
||||
// Account update (features 3043–3044)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Fetches fresh claims and updates the account if the claims have changed.
|
||||
/// Mirrors Go <c>Server.updateAccount</c>.
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public Exception? UpdateAccount(Account acc)
|
||||
{
|
||||
// Don't update more than once per second unless the account is incomplete.
|
||||
if (!acc.Incomplete && (DateTime.UtcNow - acc.Updated) < TimeSpan.FromSeconds(1))
|
||||
{
|
||||
Debugf("Requested account update for [{0}] ignored, too soon", acc.Name);
|
||||
return ServerErrors.ErrAccountResolverUpdateTooSoon;
|
||||
}
|
||||
|
||||
var (claimJwt, err) = FetchRawAccountClaims(acc.Name);
|
||||
if (err != null) return err;
|
||||
|
||||
return UpdateAccountWithClaimJwt(acc, claimJwt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies updated JWT claims to the account if they differ from what is stored.
|
||||
/// Mirrors Go <c>Server.updateAccountWithClaimJWT</c>.
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public Exception? UpdateAccountWithClaimJwt(Account acc, string claimJwt)
|
||||
{
|
||||
if (acc == null) return ServerErrors.ErrMissingAccount;
|
||||
|
||||
// If JWT hasn't changed and account is not incomplete, skip.
|
||||
if (!string.IsNullOrEmpty(acc.ClaimJwt) && acc.ClaimJwt == claimJwt && !acc.Incomplete)
|
||||
{
|
||||
Debugf("Requested account update for [{0}], same claims detected", acc.Name);
|
||||
return null;
|
||||
}
|
||||
|
||||
var (accClaims, _, verifyErr) = VerifyAccountClaims(claimJwt);
|
||||
if (verifyErr != null) return verifyErr;
|
||||
if (accClaims == null) return null;
|
||||
|
||||
if (acc.Name != accClaims.Subject)
|
||||
return ServerErrors.ErrAccountValidation;
|
||||
|
||||
acc.Issuer = accClaims.Issuer;
|
||||
// Full UpdateAccountClaims() deferred to session 11.
|
||||
acc.ClaimJwt = claimJwt;
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Account claims fetch / verify (features 3045–3048)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the raw JWT string for an account from the resolver.
|
||||
/// Mirrors Go <c>Server.fetchRawAccountClaims</c>.
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public (string Jwt, Exception? Error) FetchRawAccountClaims(string name)
|
||||
{
|
||||
if (_accResolver == null)
|
||||
return (string.Empty, ServerErrors.ErrNoAccountResolver);
|
||||
|
||||
var start = DateTime.UtcNow;
|
||||
var (jwt, err) = FetchAccountFromResolverRaw(name);
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
|
||||
if (elapsed > TimeSpan.FromSeconds(1))
|
||||
Warnf("Account [{0}] fetch took {1}", name, elapsed);
|
||||
else
|
||||
Debugf("Account [{0}] fetch took {1}", name, elapsed);
|
||||
|
||||
if (err != null)
|
||||
{
|
||||
Warnf("Account fetch failed: {0}", err);
|
||||
return (string.Empty, err);
|
||||
}
|
||||
return (jwt, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and decodes account JWT claims from the resolver.
|
||||
/// Mirrors Go <c>Server.fetchAccountClaims</c>.
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public (AccountClaims? Claims, string Jwt, Exception? Error) FetchAccountClaims(string name)
|
||||
{
|
||||
var (claimJwt, err) = FetchRawAccountClaims(name);
|
||||
if (err != null) return (null, string.Empty, err);
|
||||
|
||||
var (claims, verifiedJwt, verifyErr) = VerifyAccountClaims(claimJwt);
|
||||
if (claims != null && claims.Subject != name)
|
||||
return (null, string.Empty, ServerErrors.ErrAccountValidation);
|
||||
|
||||
return (claims, verifiedJwt, verifyErr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes and validates an account JWT string.
|
||||
/// Mirrors Go <c>Server.verifyAccountClaims</c>.
|
||||
/// </summary>
|
||||
public (AccountClaims? Claims, string Jwt, Exception? Error) VerifyAccountClaims(string claimJwt)
|
||||
{
|
||||
// Full JWT decoding deferred to session 06 JWT integration.
|
||||
// Stub: create a minimal claims object from the raw JWT.
|
||||
var claims = AccountClaims.TryDecode(claimJwt);
|
||||
if (claims == null)
|
||||
return (null, string.Empty, ServerErrors.ErrAccountValidation);
|
||||
|
||||
if (!IsTrustedIssuer(claims.Issuer))
|
||||
return (null, string.Empty, ServerErrors.ErrAccountValidation);
|
||||
|
||||
return (claims, claimJwt, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches an account from the resolver, registers it, and returns it.
|
||||
/// Mirrors Go <c>Server.fetchAccount</c>.
|
||||
/// Lock must NOT be held on entry.
|
||||
/// </summary>
|
||||
public (Account? Account, Exception? Error) FetchAccountFromResolver(string name)
|
||||
{
|
||||
var (accClaims, claimJwt, err) = FetchAccountClaims(name);
|
||||
if (accClaims == null) return (null, err);
|
||||
|
||||
var acc = BuildInternalAccount(accClaims);
|
||||
// Due to possible race, registerAccount may return an already-registered account.
|
||||
var racc = RegisterAccountInternal(acc);
|
||||
if (racc != null)
|
||||
{
|
||||
// Update with new claims if changed.
|
||||
var updateErr = UpdateAccountWithClaimJwt(racc, claimJwt);
|
||||
return updateErr != null ? (null, updateErr) : (racc, null);
|
||||
}
|
||||
|
||||
acc.ClaimJwt = claimJwt;
|
||||
return (acc, null);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Account helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Builds an Account from decoded claims.
|
||||
/// Mirrors Go <c>Server.buildInternalAccount</c>.
|
||||
/// Full JetStream limits / import / export wiring deferred to session 11.
|
||||
/// </summary>
|
||||
internal Account BuildInternalAccount(AccountClaims? claims)
|
||||
{
|
||||
var acc = new Account
|
||||
{
|
||||
Name = claims?.Subject ?? string.Empty,
|
||||
Issuer = claims?.Issuer ?? string.Empty,
|
||||
};
|
||||
return acc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the raw JWT directly from the resolver without timing/logging.
|
||||
/// </summary>
|
||||
private (string Jwt, Exception? Error) FetchAccountFromResolverRaw(string name)
|
||||
{
|
||||
if (_accResolver == null)
|
||||
return (string.Empty, ServerErrors.ErrNoAccountResolver);
|
||||
try
|
||||
{
|
||||
var jwt = _accResolver.FetchAsync(name).GetAwaiter().GetResult();
|
||||
return (jwt, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (string.Empty, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the route-pool index for an account name using FNV-32a.
|
||||
/// Mirrors Go <c>computeRoutePoolIdx</c> (route.go).
|
||||
/// </summary>
|
||||
internal static int ComputeRoutePoolIdx(int poolSize, string accountName)
|
||||
{
|
||||
if (poolSize <= 1) return 0;
|
||||
// FNV-32a hash (Go uses fnv.New32a)
|
||||
uint hash = 2166136261u;
|
||||
foreach (var b in System.Text.Encoding.UTF8.GetBytes(accountName))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= 16777619u;
|
||||
}
|
||||
return (int)(hash % (uint)poolSize);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Stubs for subsystems implemented in later sessions
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Stub: enables account tracking (session 12 — events.go).
|
||||
/// </summary>
|
||||
internal void EnableAccountTracking(Account acc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acc);
|
||||
Debugf("Enabled account tracking for {0}", acc.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub: registers system imports on an account (session 12).
|
||||
/// </summary>
|
||||
internal void RegisterSystemImports(Account acc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acc);
|
||||
acc.Imports.Services ??= new Dictionary<string, List<ServiceImportEntry>>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub: adds system-account exports (session 12).
|
||||
/// </summary>
|
||||
internal void AddSystemAccountExports(Account acc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acc);
|
||||
acc.Exports.Services ??= new Dictionary<string, ServiceExportEntry>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
365
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs
Normal file
365
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs
Normal file
@@ -0,0 +1,365 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/auth.go in the NATS server Go source.
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication logic for <see cref="NatsServer"/>.
|
||||
/// Mirrors Go auth.go Server methods.
|
||||
/// </summary>
|
||||
public sealed partial class NatsServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Wires up auth lookup tables from options.
|
||||
/// Mirrors Go <c>configureAuthorization</c>.
|
||||
/// </summary>
|
||||
internal void ConfigureAuthorization()
|
||||
{
|
||||
var opts = GetOpts();
|
||||
|
||||
if (opts.CustomClientAuthentication != null)
|
||||
{
|
||||
_info.AuthRequired = true;
|
||||
}
|
||||
else if (_trustedKeys != null)
|
||||
{
|
||||
_info.AuthRequired = true;
|
||||
}
|
||||
else if (opts.Nkeys != null || opts.Users != null)
|
||||
{
|
||||
(_nkeys, _users) = BuildNkeysAndUsersFromOptions(opts.Nkeys, opts.Users);
|
||||
_info.AuthRequired = true;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(opts.Username) || !string.IsNullOrEmpty(opts.Authorization))
|
||||
{
|
||||
_info.AuthRequired = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_users = null;
|
||||
_nkeys = null;
|
||||
_info.AuthRequired = false;
|
||||
}
|
||||
|
||||
if (opts.AuthCallout != null && string.IsNullOrEmpty(opts.AuthCallout.Account))
|
||||
Errorf("Authorization callout account not set");
|
||||
}
|
||||
|
||||
private (Dictionary<string, NkeyUser>? nkeys, Dictionary<string, User>? users) BuildNkeysAndUsersFromOptions(
|
||||
List<NkeyUser>? nko, List<User>? uo)
|
||||
{
|
||||
Dictionary<string, NkeyUser>? nkeys = null;
|
||||
Dictionary<string, User>? users = null;
|
||||
|
||||
if (nko != null)
|
||||
{
|
||||
nkeys = new Dictionary<string, NkeyUser>(nko.Count, StringComparer.Ordinal);
|
||||
foreach (var u in nko)
|
||||
{
|
||||
if (u.Permissions != null)
|
||||
AuthHandler.ValidateResponsePermissions(u.Permissions);
|
||||
nkeys[u.Nkey] = u;
|
||||
}
|
||||
}
|
||||
|
||||
if (uo != null)
|
||||
{
|
||||
users = new Dictionary<string, User>(uo.Count, StringComparer.Ordinal);
|
||||
foreach (var u in uo)
|
||||
{
|
||||
if (u.Permissions != null)
|
||||
AuthHandler.ValidateResponsePermissions(u.Permissions);
|
||||
users[u.Username] = u;
|
||||
}
|
||||
}
|
||||
|
||||
AssignGlobalAccountToOrphanUsers(nkeys, users);
|
||||
return (nkeys, users);
|
||||
}
|
||||
|
||||
internal void AssignGlobalAccountToOrphanUsers(
|
||||
Dictionary<string, NkeyUser>? nkeys,
|
||||
Dictionary<string, User>? users)
|
||||
{
|
||||
if (nkeys != null)
|
||||
foreach (var u in nkeys.Values)
|
||||
u.Account ??= _gacc;
|
||||
|
||||
if (users != null)
|
||||
foreach (var u in users.Values)
|
||||
u.Account ??= _gacc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry-point auth check — dispatches by client kind.
|
||||
/// Mirrors Go <c>checkAuthentication</c>.
|
||||
/// </summary>
|
||||
internal bool CheckAuthentication(ClientConnection c)
|
||||
{
|
||||
return c.Kind switch
|
||||
{
|
||||
ClientKind.Client => IsClientAuthorized(c),
|
||||
ClientKind.Router => IsRouterAuthorized(c),
|
||||
ClientKind.Gateway => IsGatewayAuthorized(c),
|
||||
ClientKind.Leaf => IsLeafNodeAuthorized(c),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>isClientAuthorized</c>.</summary>
|
||||
internal bool IsClientAuthorized(ClientConnection c)
|
||||
=> ProcessClientOrLeafAuthentication(c, GetOpts());
|
||||
|
||||
/// <summary>
|
||||
/// Full authentication dispatch — handles all auth paths.
|
||||
/// Mirrors Go <c>processClientOrLeafAuthentication</c>.
|
||||
/// </summary>
|
||||
internal bool ProcessClientOrLeafAuthentication(ClientConnection c, ServerOptions opts)
|
||||
{
|
||||
// Auth callout check
|
||||
if (opts.AuthCallout != null)
|
||||
return ProcessClientOrLeafCallout(c, opts);
|
||||
|
||||
// Proxy check
|
||||
var (trustedProxy, proxyOk) = ProxyCheck(c, opts);
|
||||
if (trustedProxy && !proxyOk)
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("proxy not trusted"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trusted operators / JWT bearer
|
||||
if (_trustedKeys != null)
|
||||
{
|
||||
var token = c.GetAuthToken();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("missing JWT token for trusted operator"));
|
||||
return false;
|
||||
}
|
||||
// TODO: full JWT validation against trusted operators
|
||||
return true;
|
||||
}
|
||||
|
||||
// NKey authentication
|
||||
if (_nkeys != null && _nkeys.Count > 0)
|
||||
{
|
||||
var nkeyPub = c.GetNkey();
|
||||
if (!string.IsNullOrEmpty(nkeyPub) && _nkeys.TryGetValue(nkeyPub, out var nkeyUser))
|
||||
{
|
||||
var sig = c.GetNkeySig();
|
||||
var nonce = c.GetNonce(); // byte[]?
|
||||
if (!string.IsNullOrEmpty(sig) && nonce != null && nonce.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var kp = NATS.NKeys.KeyPair.FromPublicKey(nkeyPub.AsSpan());
|
||||
// Sig is raw URL-safe base64; convert to standard base64 with padding.
|
||||
var padded = sig.Replace('-', '+').Replace('_', '/');
|
||||
var rem = padded.Length % 4;
|
||||
if (rem == 2) padded += "==";
|
||||
else if (rem == 3) padded += "=";
|
||||
var sigBytes = Convert.FromBase64String(padded);
|
||||
var verified = kp.Verify(
|
||||
new ReadOnlyMemory<byte>(nonce),
|
||||
new ReadOnlyMemory<byte>(sigBytes));
|
||||
if (!verified)
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("NKey signature verification failed"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
c.SetAuthError(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
c.SetAccount(nkeyUser.Account);
|
||||
c.SetPermissions(nkeyUser.Permissions);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Username / password
|
||||
if (_users != null && _users.Count > 0)
|
||||
{
|
||||
var username = c.GetUsername();
|
||||
if (_users.TryGetValue(username, out var user))
|
||||
{
|
||||
if (!AuthHandler.ComparePasswords(user.Password, c.GetPassword()))
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("invalid password"));
|
||||
return false;
|
||||
}
|
||||
c.SetAccount(user.Account);
|
||||
c.SetPermissions(user.Permissions);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Global username/password (from opts)
|
||||
if (!string.IsNullOrEmpty(opts.Username))
|
||||
{
|
||||
if (c.GetUsername() != opts.Username ||
|
||||
!AuthHandler.ComparePasswords(opts.Password, c.GetPassword()))
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("invalid credentials"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Token (authorization)
|
||||
if (!string.IsNullOrEmpty(opts.Authorization))
|
||||
{
|
||||
if (!AuthHandler.ComparePasswords(opts.Authorization, c.GetAuthToken()))
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("bad authorization token"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// TLS cert mapping
|
||||
if (opts.TlsMap)
|
||||
{
|
||||
var cert = c.GetTlsCertificate();
|
||||
if (!AuthHandler.CheckClientTlsCertSubject(cert, _ => true))
|
||||
{
|
||||
c.SetAuthError(new InvalidOperationException("TLS cert mapping failed"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No auth required
|
||||
if (!_info.AuthRequired) return true;
|
||||
|
||||
c.SetAuthError(new InvalidOperationException("no credentials provided"));
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>isRouterAuthorized</c>.</summary>
|
||||
internal bool IsRouterAuthorized(ClientConnection c)
|
||||
{
|
||||
var opts = GetOpts();
|
||||
if (opts.Cluster.Port == 0) return true;
|
||||
return true; // TODO: full route auth when ClusterOpts is fully typed
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>isGatewayAuthorized</c>.</summary>
|
||||
internal bool IsGatewayAuthorized(ClientConnection c)
|
||||
{
|
||||
var opts = GetOpts();
|
||||
if (string.IsNullOrEmpty(opts.Gateway.Name)) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>registerLeafWithAccount</c>.</summary>
|
||||
internal bool RegisterLeafWithAccount(ClientConnection c, string accountName)
|
||||
{
|
||||
var (acc, _) = LookupAccount(accountName);
|
||||
if (acc == null) return false;
|
||||
c.SetAccount(acc);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>isLeafNodeAuthorized</c>.</summary>
|
||||
internal bool IsLeafNodeAuthorized(ClientConnection c)
|
||||
=> ProcessClientOrLeafAuthentication(c, GetOpts());
|
||||
|
||||
/// <summary>Mirrors Go <c>checkAuthforWarnings</c>.</summary>
|
||||
internal void CheckAuthforWarnings()
|
||||
{
|
||||
var opts = GetOpts();
|
||||
if (opts.Users != null && !string.IsNullOrEmpty(opts.Username))
|
||||
Warnf("Having a global password along with users/nkeys is not recommended");
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>proxyCheck</c>.</summary>
|
||||
internal (bool TrustedProxy, bool Ok) ProxyCheck(ClientConnection c, ServerOptions opts)
|
||||
{
|
||||
if (!opts.ProxyProtocol) return (false, false);
|
||||
// TODO: check remote IP against configured trusted proxy addresses
|
||||
return (true, true);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors Go <c>processProxiesTrustedKeys</c>.</summary>
|
||||
internal void ProcessProxiesTrustedKeys()
|
||||
{
|
||||
var opts = GetOpts();
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (opts.Proxies?.Trusted is { Count: > 0 })
|
||||
{
|
||||
foreach (var proxy in opts.Proxies.Trusted)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(proxy.Key))
|
||||
keys.Add(proxy.Key.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.TrustedKeys is { Count: > 0 })
|
||||
{
|
||||
foreach (var key in opts.TrustedKeys)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
keys.Add(key.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
_proxiesKeyPairs.Clear();
|
||||
foreach (var key in keys)
|
||||
_proxiesKeyPairs.Add(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards to AuthCallout.ProcessClientOrLeafCallout.
|
||||
/// Mirrors Go <c>processClientOrLeafCallout</c>.
|
||||
/// </summary>
|
||||
internal bool ProcessClientOrLeafCallout(ClientConnection c, ServerOptions opts)
|
||||
=> AuthCallout.ProcessClientOrLeafCallout(this, c, opts);
|
||||
|
||||
/// <summary>
|
||||
/// Config reload stub.
|
||||
/// Mirrors Go <c>Server.Reload</c>.
|
||||
/// </summary>
|
||||
internal void Reload()
|
||||
{
|
||||
_reloadMu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_configTime = DateTime.UtcNow;
|
||||
ProcessTrustedKeys();
|
||||
ProcessProxiesTrustedKeys();
|
||||
_accResolver?.Reload();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reloadMu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Task that shuts the server down asynchronously.
|
||||
/// Wraps the synchronous <see cref="Shutdown"/> method.
|
||||
/// </summary>
|
||||
internal Task ShutdownAsync() => Task.Run(Shutdown);
|
||||
}
|
||||
1047
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs
Normal file
1047
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs
Normal file
File diff suppressed because it is too large
Load Diff
986
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs
Normal file
986
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs
Normal file
@@ -0,0 +1,986 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/server.go (lines 2577–4782) in the NATS server Go source.
|
||||
// Session 10: shutdown, goroutine tracking, query helpers, lame duck mode.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
public sealed partial class NatsServer
|
||||
{
|
||||
// =========================================================================
|
||||
// Shutdown / WaitForShutdown (features 3051, 3053)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Shuts the server down gracefully: closes all listeners, kicks all
|
||||
/// connections, waits for goroutines, then signals <see cref="WaitForShutdown"/>.
|
||||
/// Mirrors Go <c>Server.Shutdown()</c>.
|
||||
/// </summary>
|
||||
public void Shutdown()
|
||||
{
|
||||
// Stubs for JetStream / Raft / eventing — implemented in later sessions.
|
||||
SignalPullConsumers();
|
||||
StepdownRaftNodes();
|
||||
ShutdownEventing();
|
||||
|
||||
if (IsShuttingDown()) return;
|
||||
|
||||
_mu.EnterWriteLock();
|
||||
Noticef("Initiating Shutdown...");
|
||||
|
||||
var accRes = _accResolver;
|
||||
GetOpts(); // snapshot opts (not used below but mirrors Go pattern)
|
||||
|
||||
Interlocked.Exchange(ref _shutdown, 1);
|
||||
Interlocked.Exchange(ref _running, 0);
|
||||
lock (_grMu) { _grRunning = false; }
|
||||
|
||||
_mu.ExitWriteLock();
|
||||
|
||||
accRes?.Close();
|
||||
|
||||
ShutdownJetStream();
|
||||
ShutdownRaftNodes();
|
||||
|
||||
// ---- Collect all connections ----
|
||||
var conns = new Dictionary<ulong, ClientConnection>();
|
||||
|
||||
_mu.EnterWriteLock();
|
||||
foreach (var kvp in _clients) conns[kvp.Key] = kvp.Value;
|
||||
lock (_grMu) { foreach (var kvp in _grTmpClients) conns[kvp.Key] = kvp.Value; }
|
||||
ForEachRoute(r => { conns[r.Cid] = r; });
|
||||
GetAllGatewayConnections(conns);
|
||||
foreach (var kvp in _leafs) conns[kvp.Key] = kvp.Value;
|
||||
|
||||
// ---- Count & close listeners ----
|
||||
int doneExpected = 0;
|
||||
|
||||
if (_listener != null)
|
||||
{
|
||||
doneExpected++;
|
||||
_listener.Stop();
|
||||
_listener = null;
|
||||
}
|
||||
doneExpected += CloseWebsocketServer();
|
||||
if (_gateway.Enabled)
|
||||
{
|
||||
// mqtt listener managed by session 22
|
||||
}
|
||||
if (_leafNodeListener != null)
|
||||
{
|
||||
doneExpected++;
|
||||
_leafNodeListener.Stop();
|
||||
_leafNodeListener = null;
|
||||
}
|
||||
if (_routeListener != null)
|
||||
{
|
||||
doneExpected++;
|
||||
_routeListener.Stop();
|
||||
_routeListener = null;
|
||||
}
|
||||
if (_gatewayListener != null)
|
||||
{
|
||||
doneExpected++;
|
||||
_gatewayListener.Stop();
|
||||
_gatewayListener = null;
|
||||
}
|
||||
if (_http != null)
|
||||
{
|
||||
doneExpected++;
|
||||
_http.Stop();
|
||||
_http = null;
|
||||
}
|
||||
if (_profiler != null)
|
||||
{
|
||||
doneExpected++;
|
||||
_profiler.Stop();
|
||||
// profiler is not nulled — see Go code: it keeps _profiler ref for ProfilerAddr()
|
||||
}
|
||||
|
||||
_mu.ExitWriteLock();
|
||||
|
||||
// Release all goroutines waiting on quitCts.
|
||||
_quitCts.Cancel();
|
||||
|
||||
// Close all client / route / gateway / leaf connections.
|
||||
foreach (var c in conns.Values)
|
||||
{
|
||||
c.SetNoReconnect();
|
||||
c.CloseConnection(ClosedState.ServerShutdown);
|
||||
}
|
||||
|
||||
// Wait for accept loops to exit.
|
||||
for (int i = 0; i < doneExpected; i++)
|
||||
_done.Reader.ReadAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Wait for all goroutines.
|
||||
_grWg.Wait();
|
||||
|
||||
var opts = GetOpts();
|
||||
if (!string.IsNullOrEmpty(opts.PortsFileDir))
|
||||
DeletePortsFile(opts.PortsFileDir);
|
||||
|
||||
Noticef("Server Exiting..");
|
||||
|
||||
if (_ocsprc != null) { /* stub — stop OCSP cache in session 23 */ }
|
||||
|
||||
DisposeSignalHandlers();
|
||||
|
||||
_shutdownComplete.TrySetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until <see cref="Shutdown"/> has fully completed.
|
||||
/// Mirrors Go <c>Server.WaitForShutdown()</c>.
|
||||
/// </summary>
|
||||
public void WaitForShutdown() =>
|
||||
_shutdownComplete.Task.GetAwaiter().GetResult();
|
||||
|
||||
// =========================================================================
|
||||
// Goroutine tracking (features 3119–3120)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Starts a background Task that counts toward the server wait group.
|
||||
/// Returns true if the goroutine was started, false if the server is already stopped.
|
||||
/// Mirrors Go <c>Server.startGoRoutine(f)</c>.
|
||||
/// </summary>
|
||||
internal bool StartGoRoutine(Action f)
|
||||
{
|
||||
lock (_grMu)
|
||||
{
|
||||
if (!_grRunning) return false;
|
||||
_grWg.Add(1);
|
||||
Task.Run(() =>
|
||||
{
|
||||
try { f(); }
|
||||
finally { _grWg.Done(); }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Client / connection management (features 3081–3084)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client, route, gateway, or leaf from server accounting.
|
||||
/// Mirrors Go <c>Server.removeClient()</c>.
|
||||
/// </summary>
|
||||
internal void RemoveClient(ClientConnection c)
|
||||
{
|
||||
switch (c.Kind)
|
||||
{
|
||||
case ClientKind.Client:
|
||||
{
|
||||
bool updateProto;
|
||||
string proxyKey;
|
||||
lock (c)
|
||||
{
|
||||
updateProto = c.Kind == ClientKind.Client &&
|
||||
c.Opts.Protocol >= ClientProtocol.Info;
|
||||
proxyKey = c.ProxyKey;
|
||||
}
|
||||
_mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_clients.Remove(c.Cid);
|
||||
if (updateProto) _cproto--;
|
||||
if (!string.IsNullOrEmpty(proxyKey))
|
||||
RemoveProxiedConn(proxyKey, c.Cid);
|
||||
}
|
||||
finally { _mu.ExitWriteLock(); }
|
||||
break;
|
||||
}
|
||||
case ClientKind.Router:
|
||||
RemoveRoute(c);
|
||||
break;
|
||||
case ClientKind.Gateway:
|
||||
RemoveRemoteGatewayConnection(c);
|
||||
break;
|
||||
case ClientKind.Leaf:
|
||||
RemoveLeafNodeConnection(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a proxied connection entry.
|
||||
/// Server write lock must be held on entry.
|
||||
/// Mirrors Go <c>Server.removeProxiedConn()</c>.
|
||||
/// </summary>
|
||||
private void RemoveProxiedConn(string key, ulong cid)
|
||||
{
|
||||
if (!_proxiedConns.TryGetValue(key, out var conns)) return;
|
||||
conns.Remove(cid);
|
||||
if (conns.Count == 0) _proxiedConns.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client from the temporary goroutine client map.
|
||||
/// Mirrors Go <c>Server.removeFromTempClients()</c>.
|
||||
/// </summary>
|
||||
internal void RemoveFromTempClients(ulong cid)
|
||||
{
|
||||
lock (_grMu) { _grTmpClients.Remove(cid); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a client to the temporary goroutine client map.
|
||||
/// Returns false if the server is no longer running goroutines.
|
||||
/// Mirrors Go <c>Server.addToTempClients()</c>.
|
||||
/// </summary>
|
||||
internal bool AddToTempClients(ulong cid, ClientConnection c)
|
||||
{
|
||||
lock (_grMu)
|
||||
{
|
||||
if (!_grRunning) return false;
|
||||
_grTmpClients[cid] = c;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Query helpers (features 3085–3118, 3121–3123)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Returns the number of registered routes. Mirrors Go <c>Server.NumRoutes()</c>.</summary>
|
||||
public int NumRoutes()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return NumRoutesInternal(); }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
private int NumRoutesInternal()
|
||||
{
|
||||
int nr = 0;
|
||||
ForEachRoute(_ => nr++);
|
||||
return nr;
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of registered remotes. Mirrors Go <c>Server.NumRemotes()</c>.</summary>
|
||||
public int NumRemotes()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _routes.Count; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of leaf-node connections. Mirrors Go <c>Server.NumLeafNodes()</c>.</summary>
|
||||
public int NumLeafNodes()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _leafs.Count; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of registered clients. Mirrors Go <c>Server.NumClients()</c>.</summary>
|
||||
public int NumClients()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _clients.Count; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the client with the given connection ID. Mirrors Go <c>Server.GetClient()</c>.</summary>
|
||||
public ClientConnection? GetClient(ulong cid) => GetClientInternal(cid);
|
||||
|
||||
private ClientConnection? GetClientInternal(ulong cid)
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
_clients.TryGetValue(cid, out var c);
|
||||
return c;
|
||||
}
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the leaf node with the given connection ID. Mirrors Go <c>Server.GetLeafNode()</c>.</summary>
|
||||
public ClientConnection? GetLeafNode(ulong cid)
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try
|
||||
{
|
||||
_leafs.TryGetValue(cid, out var c);
|
||||
return c;
|
||||
}
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns total subscriptions across all accounts.
|
||||
/// Mirrors Go <c>Server.NumSubscriptions()</c>.
|
||||
/// </summary>
|
||||
public uint NumSubscriptions()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return NumSubscriptionsInternal(); }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
private uint NumSubscriptionsInternal()
|
||||
{
|
||||
int subs = 0;
|
||||
foreach (var kvp in _accounts)
|
||||
subs += kvp.Value.TotalSubs();
|
||||
return (uint)subs;
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of slow consumers. Mirrors Go <c>Server.NumSlowConsumers()</c>.</summary>
|
||||
public long NumSlowConsumers() => Interlocked.Read(ref _stats.SlowConsumers);
|
||||
|
||||
/// <summary>Returns the number of times clients were stalled. Mirrors Go <c>Server.NumStalledClients()</c>.</summary>
|
||||
public long NumStalledClients() => Interlocked.Read(ref _stats.Stalls);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumSlowConsumersClients()</c>.</summary>
|
||||
public long NumSlowConsumersClients() => Interlocked.Read(ref _scStats.Clients);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumSlowConsumersRoutes()</c>.</summary>
|
||||
public long NumSlowConsumersRoutes() => Interlocked.Read(ref _scStats.Routes);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumSlowConsumersGateways()</c>.</summary>
|
||||
public long NumSlowConsumersGateways() => Interlocked.Read(ref _scStats.Gateways);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumSlowConsumersLeafs()</c>.</summary>
|
||||
public long NumSlowConsumersLeafs() => Interlocked.Read(ref _scStats.Leafs);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumStaleConnections()</c>.</summary>
|
||||
public long NumStaleConnections() => Interlocked.Read(ref _stats.StaleConnections);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsClients()</c>.</summary>
|
||||
public long NumStaleConnectionsClients() => Interlocked.Read(ref _staleStats.Clients);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsRoutes()</c>.</summary>
|
||||
public long NumStaleConnectionsRoutes() => Interlocked.Read(ref _staleStats.Routes);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsGateways()</c>.</summary>
|
||||
public long NumStaleConnectionsGateways() => Interlocked.Read(ref _staleStats.Gateways);
|
||||
|
||||
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsLeafs()</c>.</summary>
|
||||
public long NumStaleConnectionsLeafs() => Interlocked.Read(ref _staleStats.Leafs);
|
||||
|
||||
/// <summary>Returns the time the current configuration was loaded. Mirrors Go <c>Server.ConfigTime()</c>.</summary>
|
||||
public DateTime ConfigTime()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _configTime; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the client listener address. Mirrors Go <c>Server.Addr()</c>.</summary>
|
||||
public EndPoint? Addr()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _listener?.LocalEndpoint; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the monitoring listener address. Mirrors Go <c>Server.MonitorAddr()</c>.</summary>
|
||||
public IPEndPoint? MonitorAddr()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _http?.LocalEndpoint as IPEndPoint; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the cluster (route) listener address. Mirrors Go <c>Server.ClusterAddr()</c>.</summary>
|
||||
public IPEndPoint? ClusterAddr()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _routeListener?.LocalEndpoint as IPEndPoint; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns the profiler listener address. Mirrors Go <c>Server.ProfilerAddr()</c>.</summary>
|
||||
public IPEndPoint? ProfilerAddr()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _profiler?.LocalEndpoint as IPEndPoint; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls until all expected listeners are up or the deadline expires.
|
||||
/// Returns an error description if not ready within <paramref name="d"/>.
|
||||
/// Mirrors Go <c>Server.readyForConnections()</c>.
|
||||
/// </summary>
|
||||
public Exception? ReadyForConnectionsError(TimeSpan d)
|
||||
{
|
||||
var opts = GetOpts();
|
||||
var end = DateTime.UtcNow.Add(d);
|
||||
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
bool serverOk, routeOk, gatewayOk, leafOk, wsOk;
|
||||
_mu.EnterReadLock();
|
||||
serverOk = _listener != null || opts.DontListen;
|
||||
routeOk = opts.Cluster.Port == 0 || _routeListener != null;
|
||||
gatewayOk = string.IsNullOrEmpty(opts.Gateway.Name) || _gatewayListener != null;
|
||||
leafOk = opts.LeafNode.Port == 0 || _leafNodeListener != null;
|
||||
wsOk = opts.Websocket.Port == 0; // stub — websocket listener not tracked until session 23
|
||||
_mu.ExitReadLock();
|
||||
|
||||
if (serverOk && routeOk && gatewayOk && leafOk && wsOk)
|
||||
{
|
||||
if (opts.DontListen)
|
||||
{
|
||||
try { _startupComplete.Task.Wait((int)d.TotalMilliseconds); }
|
||||
catch { /* timeout */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (d > TimeSpan.FromMilliseconds(25))
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
|
||||
return new InvalidOperationException(
|
||||
$"failed to be ready for connections after {d}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the server is ready to accept connections.
|
||||
/// Mirrors Go <c>Server.ReadyForConnections()</c>.
|
||||
/// </summary>
|
||||
public bool ReadyForConnections(TimeSpan dur) =>
|
||||
ReadyForConnectionsError(dur) == null;
|
||||
|
||||
/// <summary>Returns true if the server supports headers. Mirrors Go <c>Server.supportsHeaders()</c>.</summary>
|
||||
internal bool SupportsHeaders() => !(GetOpts().NoHeaderSupport);
|
||||
|
||||
/// <summary>Returns the server ID. Mirrors Go <c>Server.ID()</c>.</summary>
|
||||
public string ID() => _info.Id;
|
||||
|
||||
/// <summary>Returns the JetStream node name (hash of server name). Mirrors Go <c>Server.NodeName()</c>.</summary>
|
||||
public string NodeName() => GetHash(_info.Name);
|
||||
|
||||
/// <summary>Returns the server name. Mirrors Go <c>Server.Name()</c>.</summary>
|
||||
public string Name() => _info.Name;
|
||||
|
||||
/// <summary>Returns the server name as a string. Mirrors Go <c>Server.String()</c>.</summary>
|
||||
public override string ToString() => _info.Name;
|
||||
|
||||
/// <summary>Returns the number of currently-stored closed connections. Mirrors Go <c>Server.numClosedConns()</c>.</summary>
|
||||
internal int NumClosedConns()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _closed.Len(); }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns total closed connections ever recorded. Mirrors Go <c>Server.totalClosedConns()</c>.</summary>
|
||||
internal ulong TotalClosedConns()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _closed.TotalConns(); }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>Returns a snapshot of recently closed connections. Mirrors Go <c>Server.closedClients()</c>.</summary>
|
||||
internal Internal.ClosedClient?[] ClosedClients()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _closed.ClosedClients(); }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lame duck mode (features 3135–3139)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Returns true if the server is in lame duck mode. Mirrors Go <c>Server.isLameDuckMode()</c>.</summary>
|
||||
public bool IsLameDuckMode()
|
||||
{
|
||||
_mu.EnterReadLock();
|
||||
try { return _ldm; }
|
||||
finally { _mu.ExitReadLock(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a lame-duck shutdown: stops accepting new clients, notifies
|
||||
/// existing clients to reconnect elsewhere, then shuts down.
|
||||
/// Mirrors Go <c>Server.LameDuckShutdown()</c>.
|
||||
/// </summary>
|
||||
public void LameDuckShutdown() => LameDuckMode();
|
||||
|
||||
/// <summary>
|
||||
/// Core lame-duck implementation.
|
||||
/// Mirrors Go <c>Server.lameDuckMode()</c>.
|
||||
/// </summary>
|
||||
internal void LameDuckMode()
|
||||
{
|
||||
_mu.EnterWriteLock();
|
||||
if (IsShuttingDown() || _ldm || _listener == null)
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
return;
|
||||
}
|
||||
Noticef("Entering lame duck mode, stop accepting new clients");
|
||||
_ldm = true;
|
||||
SendLDMShutdownEventLocked();
|
||||
|
||||
int expected = 1;
|
||||
_listener.Stop();
|
||||
_listener = null;
|
||||
expected += CloseWebsocketServer();
|
||||
_ldmCh = System.Threading.Channels.Channel.CreateBounded<bool>(
|
||||
new System.Threading.Channels.BoundedChannelOptions(expected)
|
||||
{ FullMode = System.Threading.Channels.BoundedChannelFullMode.Wait });
|
||||
|
||||
var opts = GetOpts();
|
||||
var gp = opts.LameDuckGracePeriod;
|
||||
if (gp < TimeSpan.Zero) gp = gp.Negate();
|
||||
_mu.ExitWriteLock();
|
||||
|
||||
// Transfer Raft leaders (stub returns false).
|
||||
if (TransferRaftLeaders())
|
||||
Thread.Sleep(1000);
|
||||
|
||||
ShutdownJetStream();
|
||||
ShutdownRaftNodes();
|
||||
|
||||
// Wait for accept loops.
|
||||
for (int i = 0; i < expected; i++)
|
||||
_ldmCh.Reader.ReadAsync().GetAwaiter().GetResult();
|
||||
|
||||
_mu.EnterWriteLock();
|
||||
var clients = new List<ClientConnection>(_clients.Values);
|
||||
|
||||
if (IsShuttingDown() || clients.Count == 0)
|
||||
{
|
||||
_mu.ExitWriteLock();
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
var dur = opts.LameDuckDuration - gp;
|
||||
if (dur <= TimeSpan.Zero) dur = TimeSpan.FromSeconds(1);
|
||||
|
||||
long numClients = clients.Count;
|
||||
var si = dur / numClients;
|
||||
int batch = 1;
|
||||
|
||||
if (si < TimeSpan.FromTicks(1))
|
||||
{
|
||||
si = TimeSpan.FromTicks(1);
|
||||
batch = (int)(numClients / dur.Ticks);
|
||||
}
|
||||
else if (si > TimeSpan.FromSeconds(1))
|
||||
{
|
||||
si = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
SendLDMToRoutes();
|
||||
SendLDMToClients();
|
||||
_mu.ExitWriteLock();
|
||||
|
||||
// Grace-period delay.
|
||||
var token = _quitCts.Token;
|
||||
try { Task.Delay(gp, token).GetAwaiter().GetResult(); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
Noticef("Closing existing clients");
|
||||
for (int i = 0; i < clients.Count; i++)
|
||||
{
|
||||
clients[i].CloseConnection(ClosedState.ServerShutdown);
|
||||
if (i == clients.Count - 1) break;
|
||||
if (batch == 1 || i % batch == 0)
|
||||
{
|
||||
var jitter = (long)(Random.Shared.NextDouble() * si.Ticks);
|
||||
if (jitter < si.Ticks / 2) jitter = si.Ticks / 2;
|
||||
try { Task.Delay(TimeSpan.FromTicks(jitter), token).GetAwaiter().GetResult(); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
Shutdown();
|
||||
WaitForShutdown();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an LDM INFO to all routes.
|
||||
/// Server lock must be held on entry.
|
||||
/// Mirrors Go <c>Server.sendLDMToRoutes()</c>.
|
||||
/// </summary>
|
||||
private void SendLDMToRoutes()
|
||||
{
|
||||
_routeInfo.LameDuckMode = true;
|
||||
var infoJson = GenerateInfoJson(_routeInfo);
|
||||
ForEachRemote(r =>
|
||||
{
|
||||
lock (r) { r.EnqueueProto(infoJson); }
|
||||
});
|
||||
_routeInfo.LameDuckMode = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an LDM INFO to all connected clients.
|
||||
/// Server lock must be held on entry.
|
||||
/// Mirrors Go <c>Server.sendLDMToClients()</c>.
|
||||
/// </summary>
|
||||
private void SendLDMToClients()
|
||||
{
|
||||
_info.LameDuckMode = true;
|
||||
_clientConnectUrls.Clear();
|
||||
|
||||
_info.ClientConnectUrls = null;
|
||||
_info.WsConnectUrls = null;
|
||||
|
||||
if (!GetOpts().Cluster.NoAdvertise)
|
||||
{
|
||||
var cUrls = _clientConnectUrlsMap.GetAsStringSlice();
|
||||
_info.ClientConnectUrls = cUrls.Length > 0 ? cUrls : null;
|
||||
}
|
||||
|
||||
SendAsyncInfoToClients(true, true);
|
||||
_info.LameDuckMode = false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Rate-limit logging (features 3144–3145)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background goroutine that expires rate-limit log entries.
|
||||
/// Mirrors Go <c>Server.startRateLimitLogExpiration()</c>.
|
||||
/// </summary>
|
||||
internal void StartRateLimitLogExpiration()
|
||||
{
|
||||
StartGoRoutine(() =>
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(1);
|
||||
var token = _quitCts.Token;
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try { Task.Delay(interval, token).GetAwaiter().GetResult(); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var key in _rateLimitLogging.Keys)
|
||||
{
|
||||
if (_rateLimitLogging.TryGetValue(key, out var val) &&
|
||||
val is DateTime ts && now - ts >= interval)
|
||||
{
|
||||
_rateLimitLogging.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a new interval value.
|
||||
if (_rateLimitLoggingCh.Reader.TryRead(out var newInterval))
|
||||
interval = newInterval;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the rate-limit logging interval.
|
||||
/// Mirrors Go <c>Server.changeRateLimitLogInterval()</c>.
|
||||
/// </summary>
|
||||
internal void ChangeRateLimitLogInterval(TimeSpan d)
|
||||
{
|
||||
if (d <= TimeSpan.Zero) return;
|
||||
_rateLimitLoggingCh.Writer.TryWrite(d);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DisconnectClientByID / LDMClientByID (features 3146–3147)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Forcibly disconnects the client or leaf node with the given ID.
|
||||
/// Mirrors Go <c>Server.DisconnectClientByID()</c>.
|
||||
/// </summary>
|
||||
public Exception? DisconnectClientByID(ulong id)
|
||||
{
|
||||
var c = GetClientInternal(id);
|
||||
if (c != null) { c.CloseConnection(ClosedState.Kicked); return null; }
|
||||
c = GetLeafNode(id);
|
||||
if (c != null) { c.CloseConnection(ClosedState.Kicked); return null; }
|
||||
return new InvalidOperationException("no such client or leafnode id");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a Lame Duck Mode INFO message to the specified client.
|
||||
/// Mirrors Go <c>Server.LDMClientByID()</c>.
|
||||
/// </summary>
|
||||
public Exception? LDMClientByID(ulong id)
|
||||
{
|
||||
ClientConnection? c;
|
||||
ServerInfo info;
|
||||
_mu.EnterReadLock();
|
||||
_clients.TryGetValue(id, out c);
|
||||
if (c == null)
|
||||
{
|
||||
_mu.ExitReadLock();
|
||||
return new InvalidOperationException("no such client id");
|
||||
}
|
||||
info = CopyInfo();
|
||||
info.LameDuckMode = true;
|
||||
_mu.ExitReadLock();
|
||||
|
||||
lock (c)
|
||||
{
|
||||
if (c.Opts.Protocol >= ClientProtocol.Info &&
|
||||
(c.Flags & ClientFlags.FirstPongSent) != 0)
|
||||
{
|
||||
c.Debugf("Sending Lame Duck Mode info to client");
|
||||
c.EnqueueProto(c.GenerateClientInfoJSON(info, true).Span);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return new InvalidOperationException(
|
||||
"client does not support Lame Duck Mode or is not ready to receive the notification");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// updateRemoteSubscription / shouldReportConnectErr (features 3142–3143)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Notifies routes, gateways, and leaf nodes about a subscription change.
|
||||
/// Mirrors Go <c>Server.updateRemoteSubscription()</c>.
|
||||
/// </summary>
|
||||
internal void UpdateRemoteSubscription(Account acc, Subscription sub, int delta)
|
||||
{
|
||||
UpdateRouteSubscriptionMap(acc, sub, delta);
|
||||
if (_gateway.Enabled)
|
||||
GatewayUpdateSubInterest(acc.Name, sub, delta);
|
||||
acc.UpdateLeafNodes(sub, delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a connect error at this attempt count should be reported.
|
||||
/// Mirrors Go <c>Server.shouldReportConnectErr()</c>.
|
||||
/// </summary>
|
||||
internal bool ShouldReportConnectErr(bool firstConnect, int attempts)
|
||||
{
|
||||
var opts = GetOpts();
|
||||
int threshold = firstConnect ? opts.ConnectErrorReports : opts.ReconnectErrorReports;
|
||||
return attempts == 1 || attempts % threshold == 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Session 10 stubs for cross-session calls
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Stub — JetStream pull-consumer signalling (session 19).</summary>
|
||||
private void SignalPullConsumers()
|
||||
{
|
||||
foreach (var c in _clients.Values)
|
||||
{
|
||||
if (c.Kind == ClientKind.JetStream)
|
||||
c.FlushSignal();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stub — Raft step-down (session 20).</summary>
|
||||
private void StepdownRaftNodes()
|
||||
{
|
||||
foreach (var node in _raftNodes.Values)
|
||||
{
|
||||
var t = node.GetType();
|
||||
var stepDown = t.GetMethod("StepDown", Type.EmptyTypes);
|
||||
if (stepDown != null)
|
||||
{
|
||||
stepDown.Invoke(node, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
stepDown = t.GetMethod("StepDown", [typeof(string[])]);
|
||||
if (stepDown != null)
|
||||
stepDown.Invoke(node, [Array.Empty<string>()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stub — eventing shutdown (session 12).</summary>
|
||||
private void ShutdownEventing()
|
||||
{
|
||||
if (_sys == null)
|
||||
return;
|
||||
|
||||
_sys.Sweeper?.Dispose();
|
||||
_sys.Sweeper = null;
|
||||
_sys.StatsMsgTimer?.Dispose();
|
||||
_sys.StatsMsgTimer = null;
|
||||
_sys.Replies.Clear();
|
||||
_sys = null;
|
||||
}
|
||||
|
||||
/// <summary>Stub — JetStream shutdown (session 19).</summary>
|
||||
private void ShutdownJetStream()
|
||||
{
|
||||
_info.JetStream = false;
|
||||
}
|
||||
|
||||
/// <summary>Stub — Raft nodes shutdown (session 20).</summary>
|
||||
private void ShutdownRaftNodes()
|
||||
{
|
||||
foreach (var node in _raftNodes.Values)
|
||||
{
|
||||
var stop = node.GetType().GetMethod("Stop", Type.EmptyTypes);
|
||||
stop?.Invoke(node, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stub — Raft leader transfer (session 20). Returns false (no leaders to transfer).</summary>
|
||||
private bool TransferRaftLeaders() => false;
|
||||
|
||||
/// <summary>Stub — LDM shutdown event (session 12).</summary>
|
||||
private void SendLDMShutdownEventLocked()
|
||||
{
|
||||
_ldm = true;
|
||||
Noticef("Lame duck shutdown event emitted");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub — closes WebSocket server if running (session 23).
|
||||
/// Returns the number of done-channel signals to expect.
|
||||
/// </summary>
|
||||
private int CloseWebsocketServer() => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Iterates over all route connections. Stub — session 14.
|
||||
/// Server lock must be held on entry.
|
||||
/// </summary>
|
||||
internal void ForEachRoute(Action<ClientConnection> fn)
|
||||
{
|
||||
if (fn == null)
|
||||
return;
|
||||
|
||||
var seen = new HashSet<ulong>();
|
||||
foreach (var list in _routes.Values)
|
||||
{
|
||||
foreach (var route in list)
|
||||
{
|
||||
if (seen.Add(route.Cid))
|
||||
fn(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates over all remote (outbound route) connections. Stub — session 14.
|
||||
/// Server lock must be held on entry.
|
||||
/// </summary>
|
||||
private void ForEachRemote(Action<ClientConnection> fn) => ForEachRoute(fn);
|
||||
|
||||
/// <summary>Stub — collects all gateway connections (session 16).</summary>
|
||||
private void GetAllGatewayConnections(Dictionary<ulong, ClientConnection> conns)
|
||||
{
|
||||
foreach (var c in _gateway.Out.Values)
|
||||
conns[c.Cid] = c;
|
||||
foreach (var c in _gateway.In.Values)
|
||||
conns[c.Cid] = c;
|
||||
}
|
||||
|
||||
/// <summary>Stub — removes a route connection (session 14).</summary>
|
||||
private void RemoveRoute(ClientConnection c)
|
||||
{
|
||||
foreach (var key in _routes.Keys.ToArray())
|
||||
{
|
||||
var list = _routes[key];
|
||||
list.RemoveAll(rc => rc.Cid == c.Cid);
|
||||
if (list.Count == 0)
|
||||
_routes.Remove(key);
|
||||
}
|
||||
_clients.Remove(c.Cid);
|
||||
}
|
||||
|
||||
/// <summary>Stub — removes a remote gateway connection (session 16).</summary>
|
||||
private void RemoveRemoteGatewayConnection(ClientConnection c)
|
||||
{
|
||||
foreach (var key in _gateway.Out.Keys.ToArray())
|
||||
{
|
||||
if (_gateway.Out[key].Cid == c.Cid)
|
||||
_gateway.Out.Remove(key);
|
||||
}
|
||||
_gateway.Outo.RemoveAll(gc => gc.Cid == c.Cid);
|
||||
_gateway.In.Remove(c.Cid);
|
||||
_clients.Remove(c.Cid);
|
||||
}
|
||||
|
||||
/// <summary>Stub — removes a leaf-node connection (session 15).</summary>
|
||||
private void RemoveLeafNodeConnection(ClientConnection c)
|
||||
{
|
||||
_leafs.Remove(c.Cid);
|
||||
_clients.Remove(c.Cid);
|
||||
}
|
||||
|
||||
/// <summary>Stub — sends async INFO to clients (session 10/11). No-op until clients are running.</summary>
|
||||
private void SendAsyncInfoToClients(bool cliUpdated, bool wsUpdated)
|
||||
{
|
||||
if (!cliUpdated && !wsUpdated)
|
||||
return;
|
||||
|
||||
foreach (var c in _clients.Values)
|
||||
c.FlushSignal();
|
||||
}
|
||||
|
||||
/// <summary>Stub — updates route subscription map (session 14).</summary>
|
||||
private void UpdateRouteSubscriptionMap(Account acc, Subscription sub, int delta)
|
||||
{
|
||||
if (acc == null || sub == null || delta == 0)
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>Stub — updates gateway sub interest (session 16).</summary>
|
||||
private void GatewayUpdateSubInterest(string accName, Subscription sub, int delta)
|
||||
{
|
||||
if (string.IsNullOrEmpty(accName) || sub == null || delta == 0 || sub.Subject.Length == 0)
|
||||
return;
|
||||
|
||||
var subject = System.Text.Encoding.UTF8.GetString(sub.Subject);
|
||||
var key = sub.Queue is { Length: > 0 }
|
||||
? $"{subject} {System.Text.Encoding.UTF8.GetString(sub.Queue)}"
|
||||
: subject;
|
||||
|
||||
lock (_gateway.PasiLock)
|
||||
{
|
||||
if (!_gateway.Pasi.TryGetValue(accName, out var map))
|
||||
{
|
||||
map = new Dictionary<string, SitAlly>(StringComparer.Ordinal);
|
||||
_gateway.Pasi[accName] = map;
|
||||
}
|
||||
|
||||
if (!map.TryGetValue(key, out var tally))
|
||||
tally = new SitAlly { N = 0, Q = sub.Queue is { Length: > 0 } };
|
||||
|
||||
tally.N += delta;
|
||||
if (tally.N <= 0)
|
||||
map.Remove(key);
|
||||
else
|
||||
map[key] = tally;
|
||||
|
||||
if (map.Count == 0)
|
||||
_gateway.Pasi.Remove(accName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stub — account disconnect event (session 12).</summary>
|
||||
private void AccountDisconnectEvent(ClientConnection c, DateTime now, string reason)
|
||||
{
|
||||
var accName = c.GetAccount() is Account acc ? acc.Name : string.Empty;
|
||||
Debugf("Account disconnect: cid={0} account={1} reason={2} at={3:o}", c.Cid, accName, reason, now);
|
||||
}
|
||||
}
|
||||
1165
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Listeners.cs
Normal file
1165
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Listeners.cs
Normal file
File diff suppressed because it is too large
Load Diff
82
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs
Normal file
82
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/signal.go in the NATS server Go source.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// OS signal handling for <see cref="NatsServer"/>.
|
||||
/// Mirrors Go <c>signal.go</c>.
|
||||
/// </summary>
|
||||
public sealed partial class NatsServer
|
||||
{
|
||||
private PosixSignalRegistration? _sigHup;
|
||||
private PosixSignalRegistration? _sigTerm;
|
||||
private PosixSignalRegistration? _sigInt;
|
||||
|
||||
/// <summary>
|
||||
/// Registers OS signal handlers (SIGHUP, SIGTERM, SIGINT).
|
||||
/// On Windows, falls back to <see cref="Console.CancelKeyPress"/>.
|
||||
/// Mirrors Go <c>Server.handleSignals</c>.
|
||||
/// </summary>
|
||||
internal void HandleSignals()
|
||||
{
|
||||
if (GetOpts()?.NoSigs == true) return;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
Noticef("Caught interrupt signal, shutting down...");
|
||||
_ = ShutdownAsync();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// SIGHUP — reload configuration
|
||||
_sigHup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
|
||||
{
|
||||
ctx.Cancel = true;
|
||||
Noticef("Trapped SIGHUP signal, reloading configuration...");
|
||||
try { Reload(); }
|
||||
catch (Exception ex) { Errorf("Config reload failed: {0}", ex.Message); }
|
||||
});
|
||||
|
||||
// SIGTERM — graceful shutdown
|
||||
_sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
|
||||
{
|
||||
ctx.Cancel = true;
|
||||
Noticef("Trapped SIGTERM signal, shutting down...");
|
||||
_ = ShutdownAsync();
|
||||
});
|
||||
|
||||
// SIGINT — interrupt (Ctrl+C)
|
||||
_sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, ctx =>
|
||||
{
|
||||
ctx.Cancel = true;
|
||||
Noticef("Trapped SIGINT signal, shutting down...");
|
||||
_ = ShutdownAsync();
|
||||
});
|
||||
}
|
||||
|
||||
internal void DisposeSignalHandlers()
|
||||
{
|
||||
_sigHup?.Dispose();
|
||||
_sigTerm?.Dispose();
|
||||
_sigInt?.Dispose();
|
||||
}
|
||||
}
|
||||
335
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs
Normal file
335
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/server.go in the NATS server Go source.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
using ZB.MOM.NatsNet.Server.WebSocket;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// The core NATS server class.
|
||||
/// Mirrors Go <c>Server</c> struct in server/server.go.
|
||||
/// Session 09: initialization, configuration, and account management.
|
||||
/// Sessions 10-23 add further capabilities as partial class files.
|
||||
/// </summary>
|
||||
public sealed partial class NatsServer : INatsServer
|
||||
{
|
||||
// =========================================================================
|
||||
// Build-time stamps (mirrors package-level vars in server.go)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Binary-stamped trusted operator keys (space-separated NKey public keys).
|
||||
/// In Go this is a package-level var that can be overridden at build time.
|
||||
/// In .NET it can be set before constructing any server instance.
|
||||
/// Mirrors Go package-level <c>trustedKeys</c> var.
|
||||
/// </summary>
|
||||
public static string StampedTrustedKeys { get; set; } = string.Empty;
|
||||
|
||||
// =========================================================================
|
||||
// Atomic counters (mirrors fields accessed with atomic operations)
|
||||
// =========================================================================
|
||||
|
||||
private ulong _gcid; // global client id counter
|
||||
private long _pinnedAccFail; // pinned-account auth failures
|
||||
private int _activeAccounts; // number of active accounts
|
||||
|
||||
// =========================================================================
|
||||
// Stats (embedded Go structs: stats, scStats, staleStats)
|
||||
// =========================================================================
|
||||
|
||||
private readonly ServerStats _stats = new();
|
||||
private readonly SlowConsumerStats _scStats = new();
|
||||
private readonly InternalStaleStats _staleStats = new();
|
||||
|
||||
// =========================================================================
|
||||
// Core identity
|
||||
// =========================================================================
|
||||
|
||||
// kp / xkp are NKey keypairs — represented as byte arrays here.
|
||||
// Full crypto operations deferred to auth session.
|
||||
private byte[]? _kpSeed; // server NKey seed
|
||||
private string _pub = string.Empty; // server public key (server ID)
|
||||
private byte[]? _xkpSeed; // x25519 key seed
|
||||
private string _xpub = string.Empty; // x25519 public key
|
||||
|
||||
// =========================================================================
|
||||
// Server info (wire protocol)
|
||||
// =========================================================================
|
||||
|
||||
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion);
|
||||
private readonly ReaderWriterLockSlim _reloadMu = new(LockRecursionPolicy.SupportsRecursion);
|
||||
internal ServerInfo _info = new();
|
||||
private string _configFile = string.Empty;
|
||||
|
||||
// =========================================================================
|
||||
// Options (protected by _optsMu)
|
||||
// =========================================================================
|
||||
|
||||
private readonly ReaderWriterLockSlim _optsMu = new(LockRecursionPolicy.NoRecursion);
|
||||
private ServerOptions _opts;
|
||||
|
||||
// =========================================================================
|
||||
// Running / shutdown state
|
||||
// =========================================================================
|
||||
|
||||
private int _running; // 1 = running, 0 = not (Interlocked)
|
||||
private int _shutdown; // 1 = shutting down
|
||||
private readonly CancellationTokenSource _quitCts = new();
|
||||
private readonly TaskCompletionSource _startupComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _shutdownComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private Task? _quitTask;
|
||||
|
||||
// =========================================================================
|
||||
// Listeners (forward-declared stubs — fully wired in session 10)
|
||||
// =========================================================================
|
||||
|
||||
private System.Net.Sockets.TcpListener? _listener;
|
||||
private Exception? _listenerErr;
|
||||
|
||||
// HTTP monitoring listener
|
||||
private System.Net.Sockets.TcpListener? _http;
|
||||
|
||||
// Route listener
|
||||
private System.Net.Sockets.TcpListener? _routeListener;
|
||||
private Exception? _routeListenerErr;
|
||||
|
||||
// Gateway listener
|
||||
private System.Net.Sockets.TcpListener? _gatewayListener;
|
||||
private Exception? _gatewayListenerErr;
|
||||
|
||||
// Leaf-node listener
|
||||
private System.Net.Sockets.TcpListener? _leafNodeListener;
|
||||
private Exception? _leafNodeListenerErr;
|
||||
|
||||
// Profiling listener
|
||||
private System.Net.Sockets.TcpListener? _profiler;
|
||||
|
||||
// Accept-loop done channel — each accept loop sends true when it exits.
|
||||
private readonly System.Threading.Channels.Channel<bool> _done =
|
||||
System.Threading.Channels.Channel.CreateUnbounded<bool>();
|
||||
|
||||
// Lame-duck channel — created in lameDuckMode, receives one signal per accept loop.
|
||||
private System.Threading.Channels.Channel<bool>? _ldmCh;
|
||||
|
||||
// The no-auth user that only the system account can use (auth session).
|
||||
private string _sysAccOnlyNoAuthUser = string.Empty;
|
||||
|
||||
// =========================================================================
|
||||
// Accounts
|
||||
// =========================================================================
|
||||
|
||||
private Account? _gacc; // global account
|
||||
private Account? _sysAccAtomic; // system account (atomic)
|
||||
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, Account> _tmpAccounts = new(StringComparer.Ordinal);
|
||||
private IAccountResolver? _accResolver;
|
||||
private InternalState? _sys; // system messaging state
|
||||
|
||||
// =========================================================================
|
||||
// Client/route/leaf tracking
|
||||
// =========================================================================
|
||||
|
||||
private readonly Dictionary<ulong, ClientConnection> _clients = [];
|
||||
private readonly Dictionary<ulong, ClientConnection> _leafs = [];
|
||||
private Dictionary<string, List<ClientConnection>> _routes = [];
|
||||
private int _routesPoolSize = 1;
|
||||
private bool _routesReject;
|
||||
private int _routesNoPool;
|
||||
private Dictionary<string, Dictionary<string, ClientConnection>>? _accRoutes;
|
||||
private readonly ConcurrentDictionary<string, object?> _accRouteByHash = new(StringComparer.Ordinal);
|
||||
private Channel<struct_>? _accAddedCh; // stub
|
||||
private string _accAddedReqId = string.Empty;
|
||||
|
||||
// =========================================================================
|
||||
// User / nkey maps
|
||||
// =========================================================================
|
||||
|
||||
private Dictionary<string, Auth.User>? _users;
|
||||
private Dictionary<string, Auth.NkeyUser>? _nkeys;
|
||||
|
||||
// =========================================================================
|
||||
// Connection tracking
|
||||
// =========================================================================
|
||||
|
||||
private ulong _totalClients;
|
||||
private ClosedRingBuffer _closed = new(0);
|
||||
private DateTime _start;
|
||||
private DateTime _configTime;
|
||||
|
||||
// =========================================================================
|
||||
// Goroutine / WaitGroup tracking
|
||||
// =========================================================================
|
||||
|
||||
private readonly object _grMu = new();
|
||||
private bool _grRunning;
|
||||
private readonly Dictionary<ulong, ClientConnection> _grTmpClients = [];
|
||||
private readonly WaitGroup _grWg = new();
|
||||
|
||||
// =========================================================================
|
||||
// Cluster name (separate lock)
|
||||
// =========================================================================
|
||||
|
||||
private readonly ReaderWriterLockSlim _cnMu = new(LockRecursionPolicy.NoRecursion);
|
||||
private string _cn = string.Empty;
|
||||
private ServerInfo _routeInfo = new();
|
||||
private bool _leafNoCluster;
|
||||
private bool _leafNodeEnabled;
|
||||
private bool _leafDisableConnect;
|
||||
private bool _ldm;
|
||||
|
||||
// =========================================================================
|
||||
// Trusted keys
|
||||
// =========================================================================
|
||||
|
||||
private List<string>? _trustedKeys;
|
||||
private HashSet<string> _strictSigningKeyUsage = [];
|
||||
|
||||
// =========================================================================
|
||||
// Monitoring / stats endpoint
|
||||
// =========================================================================
|
||||
|
||||
private string _httpBasePath = string.Empty;
|
||||
private readonly Dictionary<string, ulong> _httpReqStats = [];
|
||||
|
||||
// =========================================================================
|
||||
// Client connect URLs
|
||||
// =========================================================================
|
||||
|
||||
private readonly List<string> _clientConnectUrls = [];
|
||||
private readonly RefCountedUrlSet _clientConnectUrlsMap = new();
|
||||
|
||||
// =========================================================================
|
||||
// Gateway / Websocket / MQTT / OCSP stubs
|
||||
// =========================================================================
|
||||
|
||||
private readonly SrvGateway _gateway = new();
|
||||
private readonly SrvWebsocket _websocket = new();
|
||||
private readonly SrvMqtt _mqtt = new();
|
||||
private OcspMonitor[]? _ocsps;
|
||||
private bool _ocspPeerVerify;
|
||||
private IOcspResponseCache? _ocsprc;
|
||||
|
||||
// =========================================================================
|
||||
// Gateway reply map (stub — session 16)
|
||||
// =========================================================================
|
||||
|
||||
private readonly SubscriptionIndex _gwLeafSubs;
|
||||
|
||||
// =========================================================================
|
||||
// NUID event ID generator
|
||||
// =========================================================================
|
||||
|
||||
// Replaced by actual NUID in session 10. Use Guid for now.
|
||||
private string NextEventId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
// =========================================================================
|
||||
// Various stubs
|
||||
// =========================================================================
|
||||
|
||||
private readonly List<string> _leafRemoteCfgs = []; // stub — session 15
|
||||
private readonly List<object> _proxiesKeyPairs = []; // stub — session 09 (proxies)
|
||||
private readonly Dictionary<string, Dictionary<ulong, ClientConnection>> _proxiedConns = [];
|
||||
private long _cproto; // count of INFO-capable clients
|
||||
private readonly ConcurrentDictionary<string, object?> _nodeToInfo = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, object?> _raftNodes = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _routesToSelf = [];
|
||||
private INetResolver? _routeResolver;
|
||||
private readonly ConcurrentDictionary<string, object?> _rateLimitLogging = new();
|
||||
private readonly Channel<TimeSpan> _rateLimitLoggingCh;
|
||||
private RateCounter? _connRateCounter;
|
||||
|
||||
// GW reply map expiration
|
||||
private readonly ConcurrentDictionary<string, object?> _gwrm = new();
|
||||
|
||||
// Catchup bytes
|
||||
private readonly ReaderWriterLockSlim _gcbMu = new(LockRecursionPolicy.NoRecursion);
|
||||
private long _gcbOut;
|
||||
private long _gcbOutMax;
|
||||
private readonly Channel<struct_>? _gcbKick; // stub
|
||||
|
||||
// Sync-out semaphore
|
||||
private readonly SemaphoreSlim _syncOutSem;
|
||||
private const int MaxConcurrentSyncRequests = 16;
|
||||
|
||||
// =========================================================================
|
||||
// Logging
|
||||
// =========================================================================
|
||||
|
||||
private ILogger _logger = NullLogger.Instance;
|
||||
private int _traceEnabled;
|
||||
private int _debugEnabled;
|
||||
private int _traceSysAcc;
|
||||
|
||||
// =========================================================================
|
||||
// INatsServer implementation
|
||||
// =========================================================================
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ulong NextClientId() => Interlocked.Increment(ref _gcid);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ServerOptions Options => GetOpts();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TraceEnabled => Interlocked.CompareExchange(ref _traceEnabled, 0, 0) != 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TraceSysAcc => Interlocked.CompareExchange(ref _traceSysAcc, 0, 0) != 0;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ILogger Logger => _logger;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DecActiveAccounts() => Interlocked.Decrement(ref _activeAccounts);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void IncActiveAccounts() => Interlocked.Increment(ref _activeAccounts);
|
||||
|
||||
// =========================================================================
|
||||
// Logging helpers (mirrors Go s.Debugf / s.Noticef / s.Warnf / s.Errorf)
|
||||
// =========================================================================
|
||||
|
||||
internal void Debugf(string fmt, params object?[] args) => _logger.LogDebug(fmt, args);
|
||||
internal void Noticef(string fmt, params object?[] args) => _logger.LogInformation(fmt, args);
|
||||
internal void Warnf(string fmt, params object?[] args) => _logger.LogWarning(fmt, args);
|
||||
internal void Errorf(string fmt, params object?[] args) => _logger.LogError(fmt, args);
|
||||
internal void Fatalf(string fmt, params object?[] args) => _logger.LogCritical(fmt, args);
|
||||
|
||||
// =========================================================================
|
||||
// Constructor
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Direct constructor — do not call directly; use <see cref="NewServer(ServerOptions)"/>.
|
||||
/// </summary>
|
||||
private NatsServer(ServerOptions opts)
|
||||
{
|
||||
_opts = opts;
|
||||
_gwLeafSubs = SubscriptionIndex.NewSublistWithCache();
|
||||
_rateLimitLoggingCh = Channel.CreateBounded<TimeSpan>(1);
|
||||
_syncOutSem = new SemaphoreSlim(MaxConcurrentSyncRequests, MaxConcurrentSyncRequests);
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder struct for stub channel types
|
||||
internal readonly struct struct_ { }
|
||||
373
dotnet/src/ZB.MOM.NatsNet.Server/NatsServerTypes.cs
Normal file
373
dotnet/src/ZB.MOM.NatsNet.Server/NatsServerTypes.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/server.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
using ZB.MOM.NatsNet.Server.WebSocket;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Wire-protocol Info payload
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The INFO payload sent to clients, routes, gateways and leaf nodes.
|
||||
/// Mirrors Go <c>Info</c> struct in server.go.
|
||||
/// </summary>
|
||||
public sealed class ServerInfo
|
||||
{
|
||||
[JsonPropertyName("server_id")] public string Id { get; set; } = string.Empty;
|
||||
[JsonPropertyName("server_name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("version")] public string Version { get; set; } = string.Empty;
|
||||
[JsonPropertyName("proto")] public int Proto { get; set; }
|
||||
[JsonPropertyName("git_commit")] public string? GitCommit { get; set; }
|
||||
[JsonPropertyName("go")] public string GoVersion { get; set; } = string.Empty;
|
||||
[JsonPropertyName("host")] public string Host { get; set; } = string.Empty;
|
||||
[JsonPropertyName("port")] public int Port { get; set; }
|
||||
[JsonPropertyName("headers")] public bool Headers { get; set; }
|
||||
[JsonPropertyName("auth_required")] public bool AuthRequired { get; set; }
|
||||
[JsonPropertyName("tls_required")] public bool TlsRequired { get; set; }
|
||||
[JsonPropertyName("tls_verify")] public bool TlsVerify { get; set; }
|
||||
[JsonPropertyName("tls_available")] public bool TlsAvailable { get; set; }
|
||||
[JsonPropertyName("max_payload")] public int MaxPayload { get; set; }
|
||||
[JsonPropertyName("jetstream")] public bool JetStream { get; set; }
|
||||
[JsonPropertyName("ip")] public string? Ip { get; set; }
|
||||
[JsonPropertyName("client_id")] public ulong Cid { get; set; }
|
||||
[JsonPropertyName("client_ip")] public string? ClientIp { get; set; }
|
||||
[JsonPropertyName("nonce")] public string? Nonce { get; set; }
|
||||
[JsonPropertyName("cluster")] public string? Cluster { get; set; }
|
||||
[JsonPropertyName("cluster_dynamic")] public bool Dynamic { get; set; }
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
[JsonPropertyName("connect_urls")] public string[]? ClientConnectUrls { get; set; }
|
||||
[JsonPropertyName("ws_connect_urls")] public string[]? WsConnectUrls { get; set; }
|
||||
[JsonPropertyName("ldm")] public bool LameDuckMode { get; set; }
|
||||
[JsonPropertyName("compression")] public string? Compression { get; set; }
|
||||
[JsonPropertyName("connect_info")] public bool ConnectInfo { get; set; }
|
||||
[JsonPropertyName("remote_account")] public string? RemoteAccount { get; set; }
|
||||
[JsonPropertyName("acc_is_sys")] public bool IsSystemAccount { get; set; }
|
||||
[JsonPropertyName("api_lvl")] public int JsApiLevel { get; set; }
|
||||
[JsonPropertyName("xkey")] public string? XKey { get; set; }
|
||||
|
||||
// Route-specific
|
||||
[JsonPropertyName("import")] public SubjectPermission? Import { get; set; }
|
||||
[JsonPropertyName("export")] public SubjectPermission? Export { get; set; }
|
||||
[JsonPropertyName("lnoc")] public bool Lnoc { get; set; }
|
||||
[JsonPropertyName("lnocu")] public bool Lnocu { get; set; }
|
||||
[JsonPropertyName("info_on_connect")] public bool InfoOnConnect { get; set; }
|
||||
[JsonPropertyName("route_pool_size")] public int RoutePoolSize { get; set; }
|
||||
[JsonPropertyName("route_pool_idx")] public int RoutePoolIdx { get; set; }
|
||||
[JsonPropertyName("route_account")] public string? RouteAccount { get; set; }
|
||||
[JsonPropertyName("route_acc_add_reqid")] public string? RouteAccReqId { get; set; }
|
||||
[JsonPropertyName("gossip_mode")] public byte GossipMode { get; set; }
|
||||
|
||||
// Gateway-specific
|
||||
[JsonPropertyName("gateway")] public string? Gateway { get; set; }
|
||||
[JsonPropertyName("gateway_urls")] public string[]? GatewayUrls { get; set; }
|
||||
[JsonPropertyName("gateway_url")] public string? GatewayUrl { get; set; }
|
||||
[JsonPropertyName("gateway_cmd")] public byte GatewayCmd { get; set; }
|
||||
[JsonPropertyName("gateway_cmd_payload")] public byte[]? GatewayCmdPayload { get; set; }
|
||||
[JsonPropertyName("gateway_nrp")] public bool GatewayNrp { get; set; }
|
||||
[JsonPropertyName("gateway_iom")] public bool GatewayIom { get; set; }
|
||||
|
||||
// LeafNode-specific
|
||||
[JsonPropertyName("leafnode_urls")] public string[]? LeafNodeUrls { get; set; }
|
||||
|
||||
/// <summary>Returns a shallow clone of this <see cref="ServerInfo"/>.</summary>
|
||||
internal ServerInfo ShallowClone() => (ServerInfo)MemberwiseClone();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Server stats structures
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate message/byte counters for the server.
|
||||
/// Mirrors Go embedded <c>stats</c> struct in server.go.
|
||||
/// </summary>
|
||||
internal sealed class ServerStats
|
||||
{
|
||||
public long InMsgs;
|
||||
public long OutMsgs;
|
||||
public long InBytes;
|
||||
public long OutBytes;
|
||||
public long SlowConsumers;
|
||||
public long StaleConnections;
|
||||
public long Stalls;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-kind slow-consumer counters (atomic).
|
||||
/// Mirrors Go embedded <c>scStats</c> in server.go.
|
||||
/// </summary>
|
||||
internal sealed class SlowConsumerStats
|
||||
{
|
||||
public long Clients;
|
||||
public long Routes;
|
||||
public long Leafs;
|
||||
public long Gateways;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-kind stale-connection counters (atomic, internal use only).
|
||||
/// Mirrors Go embedded <c>staleStats</c> in server.go.
|
||||
/// NOTE: The public JSON-serialisable monitoring equivalent is <c>StaleConnectionStats</c>
|
||||
/// in Events/EventTypes.cs.
|
||||
/// </summary>
|
||||
internal sealed class InternalStaleStats
|
||||
{
|
||||
public long Clients;
|
||||
public long Routes;
|
||||
public long Leafs;
|
||||
public long Gateways;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// nodeInfo — JetStream node metadata
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Per-node JetStream metadata stored in the server's node-info map.
|
||||
/// Mirrors Go <c>nodeInfo</c> struct in server.go.
|
||||
/// </summary>
|
||||
internal sealed class NodeInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public string Cluster { get; set; } = string.Empty;
|
||||
public string Domain { get; set; } = string.Empty;
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string[] Tags { get; set; } = [];
|
||||
public object? Cfg { get; set; } // JetStreamConfig — session 19
|
||||
public object? Stats { get; set; } // JetStreamStats — session 19
|
||||
public bool Offline { get; set; }
|
||||
public bool Js { get; set; }
|
||||
public bool BinarySnapshots { get; set; }
|
||||
public bool AccountNrg { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Server protocol version constants
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Server-to-server (route/leaf/gateway) protocol versions.
|
||||
/// Mirrors the iota block at the top of server.go.
|
||||
/// </summary>
|
||||
public static class ServerProtocol
|
||||
{
|
||||
/// <summary>Original route protocol (2009).</summary>
|
||||
public const int RouteProtoZero = 0;
|
||||
/// <summary>Route protocol that supports INFO updates.</summary>
|
||||
public const int RouteProtoInfo = 1;
|
||||
/// <summary>Route/cluster protocol with account support.</summary>
|
||||
public const int RouteProtoV2 = 2;
|
||||
/// <summary>Protocol with distributed message tracing.</summary>
|
||||
public const int MsgTraceProto = 3;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compression mode constants
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Compression mode string constants.
|
||||
/// Mirrors the <c>const</c> block in server.go.
|
||||
/// </summary>
|
||||
public static class CompressionMode
|
||||
{
|
||||
public const string NotSupported = "not supported";
|
||||
public const string Off = "off";
|
||||
public const string Accept = "accept";
|
||||
public const string S2Auto = "s2_auto";
|
||||
public const string S2Uncompressed = "s2_uncompressed";
|
||||
public const string S2Fast = "s2_fast";
|
||||
public const string S2Better = "s2_better";
|
||||
public const string S2Best = "s2_best";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stub types for cross-session dependencies
|
||||
// ============================================================================
|
||||
|
||||
// These stubs will be replaced with full implementations in later sessions.
|
||||
// They are declared here to allow the NatsServer class to compile.
|
||||
|
||||
// InternalState is now fully defined in Events/EventTypes.cs (session 12).
|
||||
|
||||
// JetStreamState — replaced by JsAccount in JetStream/JetStreamTypes.cs (session 19).
|
||||
// JetStreamConfig — replaced by full implementation in JetStream/JetStreamTypes.cs (session 19).
|
||||
|
||||
// SrvGateway — full class is in Gateway/GatewayTypes.cs (session 16).
|
||||
|
||||
// SrvWebsocket — now fully defined in WebSocket/WebSocketTypes.cs (session 23).
|
||||
// OcspMonitor — now fully defined in Auth/Ocsp/OcspTypes.cs (session 23).
|
||||
// IOcspResponseCache — now fully defined in Auth/Ocsp/OcspTypes.cs (session 23).
|
||||
|
||||
/// <summary>Stub for server MQTT state (session 22).</summary>
|
||||
internal sealed class SrvMqtt { }
|
||||
|
||||
/// <summary>Stub for IP queue (session 02 — already ported as IpQueue).</summary>
|
||||
// IpQueue is already in session 02 internals — used here via object.
|
||||
|
||||
// LeafNodeCfg — full class is in LeafNode/LeafNodeTypes.cs (session 15).
|
||||
|
||||
/// <summary>
|
||||
/// Network resolver used by <see cref="NatsServer.GetRandomIP"/>.
|
||||
/// Mirrors Go <c>netResolver</c> interface in server.go.
|
||||
/// </summary>
|
||||
internal interface INetResolver
|
||||
{
|
||||
Task<string[]> LookupHostAsync(string host, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Factory for rate counters.</summary>
|
||||
internal static class RateCounterFactory
|
||||
{
|
||||
public static ZB.MOM.NatsNet.Server.Internal.RateCounter Create(long rateLimit)
|
||||
=> new(rateLimit);
|
||||
}
|
||||
|
||||
// IRaftNode is now fully defined in JetStream/RaftTypes.cs (session 20).
|
||||
|
||||
// ============================================================================
|
||||
// Session 10: Ports, TlsMixConn, CaptureHttpServerLog
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Describes the URLs at which this server can be contacted.
|
||||
/// Mirrors Go <c>Ports</c> struct in server.go.
|
||||
/// </summary>
|
||||
public sealed class Ports
|
||||
{
|
||||
public string[]? Nats { get; set; }
|
||||
public string[]? Monitoring { get; set; }
|
||||
public string[]? Cluster { get; set; }
|
||||
public string[]? Profile { get; set; }
|
||||
public string[]? WebSocket { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="Stream"/> with an optional "pre-read" buffer that is
|
||||
/// drained first, then falls through to the underlying stream.
|
||||
/// Used when we peek at the first bytes of a connection to detect TLS.
|
||||
/// Mirrors Go <c>tlsMixConn</c>.
|
||||
/// </summary>
|
||||
internal sealed class TlsMixConn : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private System.IO.MemoryStream? _pre;
|
||||
|
||||
public TlsMixConn(Stream inner, byte[] preRead)
|
||||
{
|
||||
_inner = inner;
|
||||
if (preRead.Length > 0)
|
||||
_pre = new System.IO.MemoryStream(preRead, writable: false);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => _inner.CanWrite;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_pre is { } pre)
|
||||
{
|
||||
var n = pre.Read(buffer, offset, count);
|
||||
if (pre.Position >= pre.Length)
|
||||
_pre = null;
|
||||
return n;
|
||||
}
|
||||
return _inner.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) =>
|
||||
_inner.Write(buffer, offset, count);
|
||||
|
||||
public override void Flush() => _inner.Flush();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) { _pre?.Dispose(); _inner.Dispose(); }
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures HTTP server log lines and routes them through the server's
|
||||
/// error logger.
|
||||
/// Mirrors Go <c>captureHTTPServerLog</c> in server.go.
|
||||
/// </summary>
|
||||
internal sealed class CaptureHttpServerLog : System.IO.TextWriter
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly string _prefix;
|
||||
|
||||
public CaptureHttpServerLog(NatsServer server, string prefix)
|
||||
{
|
||||
_server = server;
|
||||
_prefix = prefix;
|
||||
}
|
||||
|
||||
public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8;
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
if (value is null) return;
|
||||
// Strip leading "http:" prefix that .NET's HttpListener sometimes emits.
|
||||
var msg = value.StartsWith("http:", StringComparison.Ordinal) ? value[6..] : value;
|
||||
_server.Errorf("{0}{1}", _prefix, msg.TrimEnd('\r', '\n'));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub for JWT account claims (session 06/11).
|
||||
/// Mirrors Go <c>jwt.AccountClaims</c> from nats.io/jwt/v2.
|
||||
/// Full implementation will decode a signed JWT and expose limits/imports/exports.
|
||||
/// </summary>
|
||||
public sealed class AccountClaims
|
||||
{
|
||||
/// <summary>Account public NKey (subject of the JWT).</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Operator or signing-key that issued this JWT.</summary>
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal stub decoder — returns null until session 11 provides full JWT parsing.
|
||||
/// In Go: <c>jwt.DecodeAccountClaims(claimJWT)</c>.
|
||||
/// </summary>
|
||||
public static AccountClaims? TryDecode(string claimJwt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(claimJwt)) return null;
|
||||
// TODO: implement proper JWT decoding in session 11.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.Tests")]
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.IntegrationTests")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // required for NSubstitute to proxy internal interfaces
|
||||
|
||||
185
dotnet/src/ZB.MOM.NatsNet.Server/Routes/RouteTypes.cs
Normal file
185
dotnet/src/ZB.MOM.NatsNet.Server/Routes/RouteTypes.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
// Copyright 2013-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/route.go in the NATS server Go source.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server;
|
||||
|
||||
// ============================================================================
|
||||
// Session 14: Routes
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Designates whether a route was explicitly configured or discovered via gossip.
|
||||
/// Mirrors Go <c>RouteType</c> iota in route.go.
|
||||
/// Note: Go defines Implicit=0, Explicit=1 — we keep TombStone=2 for future use.
|
||||
/// </summary>
|
||||
public enum RouteType : int
|
||||
{
|
||||
/// <summary>This route was learned from speaking to other routes.</summary>
|
||||
Implicit = 0,
|
||||
/// <summary>This route was explicitly configured.</summary>
|
||||
Explicit = 1,
|
||||
/// <summary>Reserved tombstone marker for removed routes.</summary>
|
||||
TombStone = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gossip mode constants exchanged between route servers.
|
||||
/// Mirrors the const block immediately after <c>routeInfo</c> in route.go.
|
||||
/// Do not change values — they are part of the wire protocol.
|
||||
/// </summary>
|
||||
internal static class GossipMode
|
||||
{
|
||||
public const byte Default = 0;
|
||||
public const byte Disabled = 1;
|
||||
public const byte Override = 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection route state embedded in <see cref="ClientConnection"/> when the
|
||||
/// connection kind is <c>Router</c>.
|
||||
/// Mirrors Go <c>route</c> struct in route.go.
|
||||
/// </summary>
|
||||
internal sealed class Route
|
||||
{
|
||||
/// <summary>Remote server ID string.</summary>
|
||||
public string RemoteId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Remote server name.</summary>
|
||||
public string RemoteName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>True if this server solicited the outbound connection.</summary>
|
||||
public bool DidSolicit { get; set; }
|
||||
|
||||
/// <summary>True if the connection should be retried on failure.</summary>
|
||||
public bool Retry { get; set; }
|
||||
|
||||
/// <summary>Leaf-node origin cluster flag (lnoc).</summary>
|
||||
public bool Lnoc { get; set; }
|
||||
|
||||
/// <summary>Leaf-node origin cluster with unsub support (lnocu).</summary>
|
||||
public bool Lnocu { get; set; }
|
||||
|
||||
/// <summary>Whether this is an explicit or implicit route.</summary>
|
||||
public RouteType RouteType { get; set; }
|
||||
|
||||
/// <summary>Remote URL used to establish the connection.</summary>
|
||||
public Uri? Url { get; set; }
|
||||
|
||||
/// <summary>True if the remote requires authentication.</summary>
|
||||
public bool AuthRequired { get; set; }
|
||||
|
||||
/// <summary>True if the remote requires TLS.</summary>
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
/// <summary>True if JetStream is enabled on the remote.</summary>
|
||||
public bool JetStream { get; set; }
|
||||
|
||||
/// <summary>List of client connect URLs advertised by the remote.</summary>
|
||||
public List<string> ConnectUrls { get; set; } = [];
|
||||
|
||||
/// <summary>List of WebSocket connect URLs advertised by the remote.</summary>
|
||||
public List<string> WsConnUrls { get; set; } = [];
|
||||
|
||||
/// <summary>Gateway URL advertised by the remote.</summary>
|
||||
public string GatewayUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Leaf-node URL advertised by the remote.</summary>
|
||||
public string LeafnodeUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Cluster hash used for routing.</summary>
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Server ID hash (6 bytes encoded).</summary>
|
||||
public string IdHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Index of this route in the <c>s.routes[remoteID]</c> slice.
|
||||
/// Initialized to -1 to indicate the route has not yet been registered.
|
||||
/// </summary>
|
||||
public int PoolIdx { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// When set, this route is pinned to a specific account and the account
|
||||
/// name will not be included in routed protocols.
|
||||
/// </summary>
|
||||
public byte[]? AccName { get; set; }
|
||||
|
||||
/// <summary>True if this is a connection to an old server or one with pooling disabled.</summary>
|
||||
public bool NoPool { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Selected compression mode, which may differ from the server-configured mode.
|
||||
/// </summary>
|
||||
public string Compression { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Transient gossip mode byte sent when initiating an implicit route.
|
||||
/// </summary>
|
||||
public byte GossipMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set in a pooling scenario, signals that the route should trigger
|
||||
/// creation of the next pooled connection after receiving the first PONG.
|
||||
/// </summary>
|
||||
public RouteInfo? StartNewRoute { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal descriptor used to create a new route connection, including
|
||||
/// the target URL, its type, and gossip mode.
|
||||
/// Mirrors Go <c>routeInfo</c> struct (the small inner type) in route.go.
|
||||
/// </summary>
|
||||
internal sealed class RouteInfo
|
||||
{
|
||||
public Uri? Url { get; set; }
|
||||
public RouteType RouteType { get; set; }
|
||||
public byte GossipMode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONNECT protocol payload exchanged between cluster servers.
|
||||
/// Fields map 1-to-1 with the JSON tags in Go's <c>connectInfo</c>.
|
||||
/// Mirrors Go <c>connectInfo</c> struct in route.go.
|
||||
/// </summary>
|
||||
internal sealed class ConnectInfo
|
||||
{
|
||||
[JsonPropertyName("echo")] public bool Echo { get; set; }
|
||||
[JsonPropertyName("verbose")] public bool Verbose { get; set; }
|
||||
[JsonPropertyName("pedantic")] public bool Pedantic { get; set; }
|
||||
[JsonPropertyName("user")] public string User { get; set; } = string.Empty;
|
||||
[JsonPropertyName("pass")] public string Pass { get; set; } = string.Empty;
|
||||
[JsonPropertyName("tls_required")] public bool Tls { get; set; }
|
||||
[JsonPropertyName("headers")] public bool Headers { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||
[JsonPropertyName("cluster")] public string Cluster { get; set; } = string.Empty;
|
||||
[JsonPropertyName("cluster_dynamic")] public bool Dynamic { get; set; }
|
||||
[JsonPropertyName("lnoc")] public bool Lnoc { get; set; }
|
||||
[JsonPropertyName("lnocu")] public bool Lnocu { get; set; }
|
||||
[JsonPropertyName("gateway")] public string Gateway { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds a set of subscriptions for a single account, used when fanning out
|
||||
/// route subscription interest.
|
||||
/// Mirrors Go <c>asubs</c> struct in route.go.
|
||||
/// </summary>
|
||||
internal sealed class ASubs
|
||||
{
|
||||
public Account? Account { get; set; }
|
||||
public List<Internal.Subscription> Subs { get; set; } = [];
|
||||
}
|
||||
@@ -63,6 +63,12 @@ public static class ServerConstants
|
||||
// Auth timeout — mirrors AUTH_TIMEOUT.
|
||||
public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Default auth timeout as a double (seconds) — used by ServerOptions.AuthTimeout.
|
||||
public const double DefaultAuthTimeout = 2.0;
|
||||
|
||||
// Maximum payload size alias used by config binding — mirrors MAX_PAYLOAD_SIZE.
|
||||
public const int MaxPayload = MaxPayloadSize;
|
||||
|
||||
// How often pings are sent — mirrors DEFAULT_PING_INTERVAL.
|
||||
public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2);
|
||||
|
||||
@@ -225,4 +231,6 @@ public enum ServerCommand
|
||||
Quit,
|
||||
Reopen,
|
||||
Reload,
|
||||
Term,
|
||||
LameDuckMode,
|
||||
}
|
||||
|
||||
@@ -461,17 +461,4 @@ public interface IClientAuthentication
|
||||
string RemoteAddress();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account resolver interface for dynamic account loading.
|
||||
/// Mirrors <c>AccountResolver</c> in accounts.go.
|
||||
/// </summary>
|
||||
public interface IAccountResolver
|
||||
{
|
||||
(string jwt, Exception? err) Fetch(string name);
|
||||
Exception? Store(string name, string jwt);
|
||||
bool IsReadOnly();
|
||||
Exception? Start(object server);
|
||||
bool IsTrackingUpdate();
|
||||
Exception? Reload();
|
||||
void Close();
|
||||
}
|
||||
// IAccountResolver is defined in Accounts/AccountResolver.cs.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
@@ -31,20 +32,28 @@ public sealed partial class ServerOptions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public string ConfigFile { get; set; } = string.Empty;
|
||||
[JsonPropertyName("server_name")]
|
||||
public string ServerName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
public bool DontListen { get; set; }
|
||||
[JsonPropertyName("client_advertise")]
|
||||
public string ClientAdvertise { get; set; } = string.Empty;
|
||||
public bool CheckConfig { get; set; }
|
||||
[JsonPropertyName("pid_file")]
|
||||
public string PidFile { get; set; } = string.Empty;
|
||||
[JsonPropertyName("ports_file_dir")]
|
||||
public string PortsFileDir { get; set; } = string.Empty;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Logging & Debugging
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("trace")]
|
||||
public bool Trace { get; set; }
|
||||
[JsonPropertyName("debug")]
|
||||
public bool Debug { get; set; }
|
||||
public bool TraceVerbose { get; set; }
|
||||
public bool TraceHeaders { get; set; }
|
||||
@@ -52,7 +61,9 @@ public sealed partial class ServerOptions
|
||||
public bool NoSigs { get; set; }
|
||||
public bool Logtime { get; set; }
|
||||
public bool LogtimeUtc { get; set; }
|
||||
[JsonPropertyName("logfile")]
|
||||
public string LogFile { get; set; } = string.Empty;
|
||||
[JsonPropertyName("log_size_limit")]
|
||||
public long LogSizeLimit { get; set; }
|
||||
public long LogMaxFiles { get; set; }
|
||||
public bool Syslog { get; set; }
|
||||
@@ -65,11 +76,14 @@ public sealed partial class ServerOptions
|
||||
// Networking & Limits
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("max_connections")]
|
||||
public int MaxConn { get; set; }
|
||||
public int MaxSubs { get; set; }
|
||||
public byte MaxSubTokens { get; set; }
|
||||
public int MaxControlLine { get; set; }
|
||||
[JsonPropertyName("max_payload")]
|
||||
public int MaxPayload { get; set; }
|
||||
[JsonPropertyName("max_pending")]
|
||||
public long MaxPending { get; set; }
|
||||
public bool NoFastProducerStall { get; set; }
|
||||
public bool ProxyRequired { get; set; }
|
||||
@@ -80,11 +94,16 @@ public sealed partial class ServerOptions
|
||||
// Connectivity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("ping_interval")]
|
||||
public TimeSpan PingInterval { get; set; }
|
||||
[JsonPropertyName("ping_max")]
|
||||
public int MaxPingsOut { get; set; }
|
||||
[JsonPropertyName("write_deadline")]
|
||||
public TimeSpan WriteDeadline { get; set; }
|
||||
public WriteTimeoutPolicy WriteTimeout { get; set; }
|
||||
[JsonPropertyName("lame_duck_duration")]
|
||||
public TimeSpan LameDuckDuration { get; set; }
|
||||
[JsonPropertyName("lame_duck_grace_period")]
|
||||
public TimeSpan LameDuckGracePeriod { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -92,22 +111,34 @@ public sealed partial class ServerOptions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public string HttpHost { get; set; } = string.Empty;
|
||||
[JsonPropertyName("http_port")]
|
||||
public int HttpPort { get; set; }
|
||||
[JsonPropertyName("http_base_path")]
|
||||
public string HttpBasePath { get; set; } = string.Empty;
|
||||
[JsonPropertyName("https_port")]
|
||||
public int HttpsPort { get; set; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Authentication & Authorization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
[JsonPropertyName("password")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
[JsonPropertyName("authorization")]
|
||||
public string Authorization { get; set; } = string.Empty;
|
||||
[JsonPropertyName("auth_timeout")]
|
||||
public double AuthTimeout { get; set; }
|
||||
[JsonPropertyName("no_auth_user")]
|
||||
public string NoAuthUser { get; set; } = string.Empty;
|
||||
public string DefaultSentinel { get; set; } = string.Empty;
|
||||
[JsonPropertyName("system_account")]
|
||||
public string SystemAccount { get; set; } = string.Empty;
|
||||
public bool NoSystemAccount { get; set; }
|
||||
/// <summary>Parsed account objects from config. Mirrors Go opts.Accounts.</summary>
|
||||
[JsonPropertyName("accounts")]
|
||||
public List<Account> Accounts { get; set; } = [];
|
||||
public AuthCalloutOpts? AuthCallout { get; set; }
|
||||
public bool AlwaysEnableNonce { get; set; }
|
||||
public List<User>? Users { get; set; }
|
||||
@@ -146,8 +177,11 @@ public sealed partial class ServerOptions
|
||||
// Cluster / Gateway / Leaf / WebSocket / MQTT
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
public ClusterOpts Cluster { get; set; } = new();
|
||||
[JsonPropertyName("gateway")]
|
||||
public GatewayOpts Gateway { get; set; } = new();
|
||||
[JsonPropertyName("leafnodes")]
|
||||
public LeafNodeOpts LeafNode { get; set; } = new();
|
||||
public WebsocketOpts Websocket { get; set; } = new();
|
||||
public MqttOpts Mqtt { get; set; } = new();
|
||||
@@ -163,6 +197,7 @@ public sealed partial class ServerOptions
|
||||
// JetStream
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[JsonPropertyName("jetstream")]
|
||||
public bool JetStream { get; set; }
|
||||
public bool NoJetStreamStrict { get; set; }
|
||||
public long JetStreamMaxMemory { get; set; }
|
||||
@@ -182,6 +217,7 @@ public sealed partial class ServerOptions
|
||||
public bool JetStreamMetaCompactSync { get; set; }
|
||||
public int StreamMaxBufferedMsgs { get; set; }
|
||||
public long StreamMaxBufferedSize { get; set; }
|
||||
[JsonPropertyName("store_dir")]
|
||||
public string StoreDir { get; set; } = string.Empty;
|
||||
public TimeSpan SyncInterval { get; set; }
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2020-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/websocket.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.WebSocket;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket opcode values as defined in RFC 6455 §5.2.
|
||||
/// Mirrors Go <c>wsOpCode</c> type in server/websocket.go.
|
||||
/// </summary>
|
||||
internal enum WsOpCode : int
|
||||
{
|
||||
Continuation = 0,
|
||||
Text = 1,
|
||||
Binary = 2,
|
||||
Close = 8,
|
||||
Ping = 9,
|
||||
Pong = 10,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket protocol constants.
|
||||
/// Mirrors the constant block at the top of server/websocket.go.
|
||||
/// </summary>
|
||||
internal static class WsConstants
|
||||
{
|
||||
// Frame header bits
|
||||
public const int FinalBit = 1 << 7;
|
||||
public const int Rsv1Bit = 1 << 6; // Used for per-message compression (RFC 7692)
|
||||
public const int Rsv2Bit = 1 << 5;
|
||||
public const int Rsv3Bit = 1 << 4;
|
||||
public const int MaskBit = 1 << 7;
|
||||
|
||||
// Frame size limits
|
||||
public const int MaxFrameHeaderSize = 14; // LeafNode may behave as a client
|
||||
public const int MaxControlPayloadSize = 125;
|
||||
public const int FrameSizeForBrowsers = 4096; // From experiment, browsers behave better with limited frame size
|
||||
public const int CompressThreshold = 64; // Don't compress for small buffer(s)
|
||||
public const int CloseStatusSize = 2;
|
||||
|
||||
// Close status codes (RFC 6455 §11.7)
|
||||
public const int CloseNormalClosure = 1000;
|
||||
public const int CloseGoingAway = 1001;
|
||||
public const int CloseProtocolError = 1002;
|
||||
public const int CloseUnsupportedData = 1003;
|
||||
public const int CloseNoStatusReceived = 1005;
|
||||
public const int CloseInvalidPayloadData = 1007;
|
||||
public const int ClosePolicyViolation = 1008;
|
||||
public const int CloseMessageTooBig = 1009;
|
||||
public const int CloseInternalError = 1011;
|
||||
public const int CloseTlsHandshake = 1015;
|
||||
|
||||
// Header strings
|
||||
public const string NoMaskingHeader = "Nats-No-Masking";
|
||||
public const string NoMaskingValue = "true";
|
||||
public const string XForwardedForHeader = "X-Forwarded-For";
|
||||
public const string PMCExtension = "permessage-deflate"; // per-message compression
|
||||
public const string PMCSrvNoCtx = "server_no_context_takeover";
|
||||
public const string PMCCliNoCtx = "client_no_context_takeover";
|
||||
public const string SecProtoHeader = "Sec-Websocket-Protocol";
|
||||
public const string MQTTSecProtoVal = "mqtt";
|
||||
public const string SchemePrefix = "ws";
|
||||
public const string SchemePrefixTls = "wss";
|
||||
}
|
||||
110
dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketTypes.cs
Normal file
110
dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/WebSocketTypes.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright 2020-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/websocket.go in the NATS server Go source.
|
||||
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.WebSocket;
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection WebSocket read state.
|
||||
/// Mirrors Go <c>wsReadInfo</c> struct in server/websocket.go.
|
||||
/// </summary>
|
||||
internal sealed class WsReadInfo
|
||||
{
|
||||
/// <summary>Whether masking is disabled for this connection (e.g. leaf node).</summary>
|
||||
public bool NoMasking { get; set; }
|
||||
|
||||
/// <summary>Whether per-message deflate compression is active.</summary>
|
||||
public bool Compressed { get; set; }
|
||||
|
||||
/// <summary>The current frame opcode.</summary>
|
||||
public WsOpCode FrameType { get; set; }
|
||||
|
||||
/// <summary>Number of payload bytes remaining in the current frame.</summary>
|
||||
public int PayloadLeft { get; set; }
|
||||
|
||||
/// <summary>The 4-byte masking key (only valid when masking is active).</summary>
|
||||
public int[] Mask { get; set; } = new int[4];
|
||||
|
||||
/// <summary>Current offset into <see cref="Mask"/>.</summary>
|
||||
public int MaskOffset { get; set; }
|
||||
|
||||
/// <summary>Accumulated compressed payload buffers awaiting decompression.</summary>
|
||||
public byte[]? Compress { get; set; }
|
||||
|
||||
public WsReadInfo() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-level WebSocket state, shared across all WebSocket connections.
|
||||
/// Mirrors Go <c>srvWebsocket</c> struct in server/websocket.go.
|
||||
/// Replaces the stub in NatsServerTypes.cs.
|
||||
/// </summary>
|
||||
internal sealed class SrvWebsocket
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks WebSocket connect URLs per server (ref-counted).
|
||||
/// Mirrors Go <c>connectURLsMap refCountedUrlSet</c>.
|
||||
/// </summary>
|
||||
public RefCountedUrlSet ConnectUrlsMap { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TLS configuration for the WebSocket listener.
|
||||
/// Mirrors Go <c>tls bool</c> field (true if TLS is required).
|
||||
/// </summary>
|
||||
public System.Net.Security.SslServerAuthenticationOptions? TlsConfig { get; set; }
|
||||
|
||||
/// <summary>Whether per-message deflate compression is enabled globally.</summary>
|
||||
public bool Compression { get; set; }
|
||||
|
||||
/// <summary>Host the WebSocket server is listening on.</summary>
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Port the WebSocket server is listening on (may be ephemeral).</summary>
|
||||
public int Port { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles WebSocket upgrade and framing for a single connection.
|
||||
/// Mirrors the WebSocket-related methods on Go <c>client</c> in server/websocket.go.
|
||||
/// Full implementation is deferred to session 23.
|
||||
/// </summary>
|
||||
internal sealed class WebSocketHandler
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
|
||||
public WebSocketHandler(NatsServer server)
|
||||
{
|
||||
_server = server;
|
||||
}
|
||||
|
||||
/// <summary>Upgrades an HTTP connection to WebSocket protocol.</summary>
|
||||
public void UpgradeToWebSocket(
|
||||
System.IO.Stream stream,
|
||||
System.Net.Http.Headers.HttpRequestHeaders headers)
|
||||
=> throw new NotImplementedException("TODO: session 23 — websocket");
|
||||
|
||||
/// <summary>Parses a WebSocket frame from the given buffer slice.</summary>
|
||||
public void ParseFrame(byte[] data, int offset, int count)
|
||||
=> throw new NotImplementedException("TODO: session 23 — websocket");
|
||||
|
||||
/// <summary>Writes a WebSocket frame with the given payload.</summary>
|
||||
public void WriteFrame(WsOpCode opCode, byte[] payload, bool final, bool compress)
|
||||
=> throw new NotImplementedException("TODO: session 23 — websocket");
|
||||
|
||||
/// <summary>Writes a WebSocket close frame with the given status code and reason.</summary>
|
||||
public void WriteCloseFrame(int statusCode, string reason)
|
||||
=> throw new NotImplementedException("TODO: session 23 — websocket");
|
||||
}
|
||||
@@ -9,10 +9,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="*" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="*" />
|
||||
<PackageReference Include="IronSnappy" Version="*" />
|
||||
<PackageReference Include="NATS.NKeys" Version="1.0.0-preview.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using System.Threading.Channels;
|
||||
using NATS.Client.Core;
|
||||
using Shouldly;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral baseline tests against the reference Go NATS server.
|
||||
/// These tests require a running Go NATS server on localhost:4222.
|
||||
/// Start with: cd golang/nats-server && go run . -p 4222
|
||||
/// </summary>
|
||||
[Collection("NatsIntegration")]
|
||||
[Trait("Category", "Integration")]
|
||||
public class NatsServerBehaviorTests : IAsyncLifetime
|
||||
{
|
||||
private NatsConnection? _nats;
|
||||
private Exception? _initFailure;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_nats = new NatsConnection(new NatsOpts { Url = "nats://localhost:4222" });
|
||||
await _nats.ConnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_initFailure = ex;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_nats is not null)
|
||||
await _nats.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the server is not available, causing the calling test to return early (pass silently).
|
||||
/// xUnit 2.x does not support dynamic skip at runtime; early return is the pragmatic workaround.
|
||||
/// </summary>
|
||||
private bool ServerUnavailable() => _initFailure != null;
|
||||
|
||||
[Fact]
|
||||
public async Task BasicPubSub_ShouldDeliverMessage()
|
||||
{
|
||||
if (ServerUnavailable()) return;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var received = new TaskCompletionSource<string>();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var msg in _nats!.SubscribeAsync<string>("test.hello", cancellationToken: cts.Token))
|
||||
{
|
||||
received.TrySetResult(msg.Data ?? "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
received.TrySetException(ex);
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
// Give subscriber a moment to register
|
||||
await Task.Delay(100, cts.Token);
|
||||
await _nats!.PublishAsync("test.hello", "world");
|
||||
var result = await received.Task.WaitAsync(cts.Token);
|
||||
result.ShouldBe("world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WildcardSubscription_DotStar_ShouldMatch()
|
||||
{
|
||||
if (ServerUnavailable()) return;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var received = new TaskCompletionSource<string>();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var msg in _nats!.SubscribeAsync<string>("foo.*", cancellationToken: cts.Token))
|
||||
{
|
||||
received.TrySetResult(msg.Subject);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
received.TrySetException(ex);
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
await Task.Delay(100, cts.Token);
|
||||
await _nats!.PublishAsync("foo.bar", "payload");
|
||||
var subject = await received.Task.WaitAsync(cts.Token);
|
||||
subject.ShouldBe("foo.bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WildcardSubscription_GreaterThan_ShouldMatchMultiLevel()
|
||||
{
|
||||
if (ServerUnavailable()) return;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var received = new TaskCompletionSource<string>();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var msg in _nats!.SubscribeAsync<string>("foo.>", cancellationToken: cts.Token))
|
||||
{
|
||||
received.TrySetResult(msg.Subject);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
received.TrySetException(ex);
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
await Task.Delay(100, cts.Token);
|
||||
await _nats!.PublishAsync("foo.bar.baz", "payload");
|
||||
var subject = await received.Task.WaitAsync(cts.Token);
|
||||
subject.ShouldBe("foo.bar.baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGroup_ShouldDeliverToOnlyOneSubscriber()
|
||||
{
|
||||
if (ServerUnavailable()) return;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
const int messageCount = 30;
|
||||
var channel = Channel.CreateBounded<int>(messageCount * 2);
|
||||
var count1 = 0;
|
||||
var count2 = 0;
|
||||
|
||||
var reader1 = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var _ in _nats!.SubscribeAsync<string>("qg.test", queueGroup: "workers", cancellationToken: cts.Token))
|
||||
{
|
||||
Interlocked.Increment(ref count1);
|
||||
await channel.Writer.WriteAsync(1, cts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
});
|
||||
|
||||
var reader2 = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var _ in _nats!.SubscribeAsync<string>("qg.test", queueGroup: "workers", cancellationToken: cts.Token))
|
||||
{
|
||||
Interlocked.Increment(ref count2);
|
||||
await channel.Writer.WriteAsync(1, cts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
});
|
||||
|
||||
// Give subscribers a moment to register
|
||||
await Task.Delay(200, cts.Token);
|
||||
|
||||
for (var i = 0; i < messageCount; i++)
|
||||
await _nats!.PublishAsync("qg.test", $"msg{i}");
|
||||
|
||||
// Wait for all messages to be received
|
||||
var received = 0;
|
||||
while (received < messageCount)
|
||||
{
|
||||
await channel.Reader.ReadAsync(cts.Token);
|
||||
received++;
|
||||
}
|
||||
|
||||
(count1 + count2).ShouldBe(messageCount);
|
||||
// Don't assert per-subscriber counts — distribution is probabilistic
|
||||
|
||||
cts.Cancel();
|
||||
await Task.WhenAll(reader1, reader2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectDisconnect_ShouldNotThrow()
|
||||
{
|
||||
if (ServerUnavailable()) return;
|
||||
|
||||
var nats2 = new NatsConnection(new NatsOpts { Url = "nats://localhost:4222" });
|
||||
await Should.NotThrowAsync(async () =>
|
||||
{
|
||||
await nats2.ConnectAsync();
|
||||
await nats2.DisposeAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NATS.Client.Core" Version="2.7.2" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="Shouldly" Version="*" />
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Copyright 2018-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/accounts_test.go and server/dirstore_test.go in the NATS server Go source.
|
||||
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests;
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class AccountTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Account Basic Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 1
|
||||
[Fact]
|
||||
public void NewAccount_SetsNameAndUnlimitedLimits()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.Name.ShouldBe("foo");
|
||||
acc.MaxConnections.ShouldBe(-1);
|
||||
acc.MaxLeafNodes.ShouldBe(-1);
|
||||
}
|
||||
|
||||
// Test 2
|
||||
[Fact]
|
||||
public void ToString_ReturnsName()
|
||||
{
|
||||
var acc = Account.NewAccount("myaccount");
|
||||
|
||||
acc.ToString().ShouldBe(acc.Name);
|
||||
}
|
||||
|
||||
// Test 3
|
||||
[Fact]
|
||||
public void IsExpired_InitiallyFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.IsExpired().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 4
|
||||
[Fact]
|
||||
public void IsClaimAccount_NoJwt_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
// ClaimJwt defaults to empty string
|
||||
acc.IsClaimAccount().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 5
|
||||
[Fact]
|
||||
public void NumConnections_Initial_IsZero()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.NumConnections().ShouldBe(0);
|
||||
}
|
||||
|
||||
// Test 6
|
||||
[Fact]
|
||||
public void GetName_ReturnsName()
|
||||
{
|
||||
var acc = Account.NewAccount("thread-safe-name");
|
||||
|
||||
acc.GetName().ShouldBe("thread-safe-name");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Subject Mapping Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 7
|
||||
[Fact]
|
||||
public void AddMapping_ValidSubject_Succeeds()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddMapping("foo", "bar");
|
||||
|
||||
err.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Test 8
|
||||
[Fact]
|
||||
public void AddMapping_InvalidSubject_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddMapping("foo..bar", "x");
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Test 9
|
||||
[Fact]
|
||||
public void RemoveMapping_ExistingMapping_ReturnsTrue()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
|
||||
var removed = acc.RemoveMapping("foo");
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Test 10
|
||||
[Fact]
|
||||
public void RemoveMapping_NonExistentMapping_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var removed = acc.RemoveMapping("nonexistent");
|
||||
|
||||
removed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 11
|
||||
[Fact]
|
||||
public void HasMappings_AfterAdd_ReturnsTrue()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
|
||||
acc.HasMappings().ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Test 12
|
||||
[Fact]
|
||||
public void HasMappings_AfterRemove_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
acc.RemoveMapping("foo");
|
||||
|
||||
acc.HasMappings().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 13
|
||||
[Fact]
|
||||
public void SelectMappedSubject_NoMapping_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var (dest, mapped) = acc.SelectMappedSubject("foo");
|
||||
|
||||
mapped.ShouldBeFalse();
|
||||
dest.ShouldBe("foo");
|
||||
}
|
||||
|
||||
// Test 14
|
||||
[Fact]
|
||||
public void SelectMappedSubject_SimpleMapping_ReturnsMappedDest()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
|
||||
var (dest, mapped) = acc.SelectMappedSubject("foo");
|
||||
|
||||
mapped.ShouldBeTrue();
|
||||
dest.ShouldBe("bar");
|
||||
}
|
||||
|
||||
// Test 15
|
||||
[Fact]
|
||||
public void AddWeightedMappings_DuplicateDest_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddWeightedMappings("src",
|
||||
MapDest.New("dest1", 50),
|
||||
MapDest.New("dest1", 50)); // duplicate subject
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Test 16
|
||||
[Fact]
|
||||
public void AddWeightedMappings_WeightOver100_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddWeightedMappings("src",
|
||||
MapDest.New("dest1", 101)); // weight exceeds 100
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Test 17
|
||||
[Fact]
|
||||
public void AddWeightedMappings_TotalWeightOver100_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddWeightedMappings("src",
|
||||
MapDest.New("dest1", 80),
|
||||
MapDest.New("dest2", 80)); // total = 160
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Connection Counting Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 18
|
||||
[Fact]
|
||||
public void NumLeafNodes_Initial_IsZero()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.NumLeafNodes().ShouldBe(0);
|
||||
}
|
||||
|
||||
// Test 19
|
||||
[Fact]
|
||||
public void MaxTotalConnectionsReached_UnlimitedAccount_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
// MaxConnections is -1 (unlimited) by default
|
||||
|
||||
acc.MaxTotalConnectionsReached().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 20
|
||||
[Fact]
|
||||
public void MaxTotalLeafNodesReached_UnlimitedAccount_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
// MaxLeafNodes is -1 (unlimited) by default
|
||||
|
||||
acc.MaxTotalLeafNodesReached().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Export Service Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 21
|
||||
[Fact]
|
||||
public void IsExportService_NoExports_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.IsExportService("my.service").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 22
|
||||
[Fact]
|
||||
public void IsExportServiceTracking_NoExports_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.IsExportServiceTracking("my.service").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DirJwtStore Tests
|
||||
// =========================================================================
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class DirJwtStoreTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
private string MakeTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(dir);
|
||||
_tempDirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
{
|
||||
try { Directory.Delete(dir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Test 23
|
||||
[Fact]
|
||||
public void DirJwtStore_WriteAndRead_Succeeds()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
const string key = "AAAAAAAAAA"; // minimum 2-char key
|
||||
const string jwt = "header.payload.signature";
|
||||
|
||||
store.SaveAcc(key, jwt);
|
||||
var loaded = store.LoadAcc(key);
|
||||
|
||||
loaded.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
// Test 24
|
||||
[Fact]
|
||||
public void DirJwtStore_ShardedWriteAndRead_Succeeds()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: true, create: false);
|
||||
|
||||
var keys = new[] { "ACCTKEY001", "ACCTKEY002", "ACCTKEY003" };
|
||||
foreach (var k in keys)
|
||||
{
|
||||
store.SaveAcc(k, $"jwt.for.{k}");
|
||||
}
|
||||
|
||||
foreach (var k in keys)
|
||||
{
|
||||
store.LoadAcc(k).ShouldBe($"jwt.for.{k}");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 25
|
||||
[Fact]
|
||||
public void DirJwtStore_EmptyKey_ReturnsError()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
// LoadAcc with key shorter than 2 chars should throw
|
||||
Should.Throw<Exception>(() => store.LoadAcc(""));
|
||||
|
||||
// SaveAcc with key shorter than 2 chars should throw
|
||||
Should.Throw<Exception>(() => store.SaveAcc("", "some.jwt"));
|
||||
}
|
||||
|
||||
// Test 26
|
||||
[Fact]
|
||||
public void DirJwtStore_MissingKey_ReturnsError()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
Should.Throw<FileNotFoundException>(() => store.LoadAcc("NONEXISTENT_KEY"));
|
||||
}
|
||||
|
||||
// Test 27
|
||||
[Fact]
|
||||
public void DirJwtStore_Pack_ContainsSavedJwts()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
store.SaveAcc("ACCTKEYAAA", "jwt1.data.sig");
|
||||
store.SaveAcc("ACCTKEYBBB", "jwt2.data.sig");
|
||||
|
||||
var packed = store.Pack(-1);
|
||||
|
||||
packed.ShouldContain("ACCTKEYAAA|jwt1.data.sig");
|
||||
packed.ShouldContain("ACCTKEYBBB|jwt2.data.sig");
|
||||
}
|
||||
|
||||
// Test 28
|
||||
[Fact]
|
||||
public void DirJwtStore_Merge_AddsNewEntries()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
// Pack format: key|jwt lines separated by newline
|
||||
var packData = "ACCTKEYMERGE|merged.jwt.value";
|
||||
store.Merge(packData);
|
||||
|
||||
var loaded = store.LoadAcc("ACCTKEYMERGE");
|
||||
loaded.ShouldBe("merged.jwt.value");
|
||||
}
|
||||
|
||||
// Test 29
|
||||
[Fact]
|
||||
public void DirJwtStore_ReadOnly_Prevents_Write()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
// Write a file first so the dir is valid
|
||||
var writeable = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
writeable.SaveAcc("ACCTKEYRO", "original.jwt");
|
||||
writeable.Dispose();
|
||||
|
||||
// Open as immutable
|
||||
using var readOnly = DirJwtStore.NewImmutableDirJwtStore(dir, shard: false);
|
||||
|
||||
readOnly.IsReadOnly().ShouldBeTrue();
|
||||
Should.Throw<InvalidOperationException>(() => readOnly.SaveAcc("ACCTKEYRO", "new.jwt"));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MemoryAccountResolver Tests
|
||||
// =========================================================================
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class MemoryAccountResolverTests
|
||||
{
|
||||
// Test 30
|
||||
[Fact]
|
||||
public async Task MemoryAccountResolver_StoreAndFetch_Roundtrip()
|
||||
{
|
||||
var resolver = new MemoryAccountResolver();
|
||||
const string key = "MYACCOUNTKEY";
|
||||
const string jwt = "header.payload.sig";
|
||||
|
||||
await resolver.StoreAsync(key, jwt);
|
||||
var fetched = await resolver.FetchAsync(key);
|
||||
|
||||
fetched.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
// Test 31
|
||||
[Fact]
|
||||
public async Task MemoryAccountResolver_Fetch_MissingKey_Throws()
|
||||
{
|
||||
var resolver = new MemoryAccountResolver();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => resolver.FetchAsync("DOESNOTEXIST"));
|
||||
}
|
||||
|
||||
// Test 32
|
||||
[Fact]
|
||||
public void MemoryAccountResolver_IsReadOnly_ReturnsFalse()
|
||||
{
|
||||
var resolver = new MemoryAccountResolver();
|
||||
|
||||
resolver.IsReadOnly().ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UrlAccountResolver Tests
|
||||
// =========================================================================
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class UrlAccountResolverTests
|
||||
{
|
||||
// Test 33
|
||||
[Fact]
|
||||
public void UrlAccountResolver_NormalizesTrailingSlash()
|
||||
{
|
||||
// Two constructors: one with slash, one without.
|
||||
// We verify construction doesn't throw and the resolver is usable.
|
||||
// (We cannot inspect _url directly since it's private, but we can
|
||||
// infer correctness via IsReadOnly and lack of constructor exception.)
|
||||
var resolverNoSlash = new UrlAccountResolver("http://localhost:9090");
|
||||
var resolverWithSlash = new UrlAccountResolver("http://localhost:9090/");
|
||||
|
||||
// Both should construct without error and have the same observable behaviour.
|
||||
resolverNoSlash.IsReadOnly().ShouldBeTrue();
|
||||
resolverWithSlash.IsReadOnly().ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Test 34
|
||||
[Fact]
|
||||
public void UrlAccountResolver_IsReadOnly_ReturnsTrue()
|
||||
{
|
||||
var resolver = new UrlAccountResolver("http://localhost:9090");
|
||||
|
||||
resolver.IsReadOnly().ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Mirrors server/dirstore_test.go tests 285–296 in the NATS server Go source.
|
||||
// The Go tests use nkeys.CreateAccount() + jwt.NewAccountClaims() to generate
|
||||
// real signed JWTs. Here we craft minimal fake JWT strings directly using
|
||||
// Base64URL-encoded JSON payloads, since DirJwtStore only parses the "exp",
|
||||
// "iat" and "jti" numeric/string claims from the payload.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DirJwtStore"/> expiration, limits, LRU eviction,
|
||||
/// reload, TTL and notification behaviour.
|
||||
/// Mirrors server/dirstore_test.go tests 285–296.
|
||||
/// </summary>
|
||||
[Collection("DirectoryStoreTests")]
|
||||
public sealed class DirectoryStoreTests : IDisposable
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Counter for unique public-key names
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static int _counter;
|
||||
|
||||
private static string NextKey() =>
|
||||
$"ACCT{Interlocked.Increment(ref _counter):D8}";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Temp directory management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
private string MakeTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dirstore_" + Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(dir);
|
||||
_tempDirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers — fake JWT construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal fake JWT string: header.payload.signature
|
||||
/// where the payload contains "exp", "iat" and "jti" claims.
|
||||
/// </summary>
|
||||
private static string MakeFakeJwt(
|
||||
long expUnixSeconds,
|
||||
long iatUnixSeconds = 0,
|
||||
string? jti = null)
|
||||
{
|
||||
if (iatUnixSeconds == 0)
|
||||
iatUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
jti ??= Guid.NewGuid().ToString("N");
|
||||
|
||||
var payloadObj = expUnixSeconds > 0
|
||||
? $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds},\"exp\":{expUnixSeconds}}}"
|
||||
: $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds}}}";
|
||||
|
||||
var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"ed25519-nkey\",\"typ\":\"JWT\"}"));
|
||||
var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadObj));
|
||||
var sigB64 = Base64UrlEncode(new byte[64]); // dummy 64-byte signature
|
||||
return $"{headerB64}.{payloadB64}.{sigB64}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rounds a <see cref="DateTimeOffset"/> to the nearest whole second,
|
||||
/// mirroring Go's <c>time.Now().Round(time.Second)</c>.
|
||||
/// </summary>
|
||||
private static DateTimeOffset RoundToSecond(DateTimeOffset dt) =>
|
||||
dt.Millisecond >= 500
|
||||
? new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset).AddSeconds(1)
|
||||
: new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);
|
||||
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and saves a test account JWT in the store.
|
||||
/// <paramref name="expSec"/> == 0 means no expiration.
|
||||
/// Returns the saved JWT string.
|
||||
/// </summary>
|
||||
private static string CreateTestAccount(DirJwtStore store, string pubKey, int expSec)
|
||||
{
|
||||
long exp = expSec > 0
|
||||
// Round to the nearest second first (mirrors Go's time.Now().Round(time.Second).Add(...).Unix()),
|
||||
// ensuring the expiry is at a whole-second boundary and avoiding sub-second truncation races.
|
||||
? RoundToSecond(DateTimeOffset.UtcNow).AddSeconds(expSec).ToUnixTimeSeconds()
|
||||
: 0;
|
||||
var theJwt = MakeFakeJwt(exp);
|
||||
store.SaveAcc(pubKey, theJwt);
|
||||
return theJwt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts non-deleted .jwt files in <paramref name="dir"/> recursively.
|
||||
/// </summary>
|
||||
private static int CountJwtFiles(string dir) =>
|
||||
Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
|
||||
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:285 — TestExpiration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:285
|
||||
public async Task Expiration_ExpiredAccountIsRemovedByBackground()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var hBegin = store.Hash();
|
||||
|
||||
// Add one account that should NOT expire (100-second TTL).
|
||||
var keyNoExp = NextKey();
|
||||
CreateTestAccount(store, keyNoExp, 100);
|
||||
var hNoExp = store.Hash();
|
||||
hNoExp.ShouldNotBe(hBegin);
|
||||
|
||||
// Add one account that should expire in ~1 second.
|
||||
var keyExp = NextKey();
|
||||
CreateTestAccount(store, keyExp, 1);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Wait up to 4 s for the expired file to vanish.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(4);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
if (CountJwtFiles(dir) == 1)
|
||||
break;
|
||||
}
|
||||
|
||||
CountJwtFiles(dir).ShouldBe(1, "expired account should be removed");
|
||||
|
||||
// Hash after expiry should equal hash after adding only the non-expiring key.
|
||||
var lh = store.Hash();
|
||||
lh.ShouldBe(hNoExp);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:286 — TestLimit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:286
|
||||
public void Limit_LruEvictsOldestEntries()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||||
limit: 5,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var h = store.Hash();
|
||||
|
||||
// Update the first account 10 times — should remain as 1 entry.
|
||||
var firstKey = NextKey();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
CreateTestAccount(store, firstKey, 50);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
}
|
||||
|
||||
// Add 10 more new accounts — limit is 5, LRU eviction kicks in.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var k = NextKey();
|
||||
CreateTestAccount(store, k, i + 1); // short but non-zero expiry
|
||||
var nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
}
|
||||
|
||||
// After all adds, only 5 files should remain.
|
||||
CountJwtFiles(dir).ShouldBe(5);
|
||||
|
||||
// The first account should have been evicted.
|
||||
File.Exists(Path.Combine(dir, firstKey + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Updating the first account again should succeed (limit allows eviction).
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
CreateTestAccount(store, firstKey, 50);
|
||||
CountJwtFiles(dir).ShouldBe(5);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:287 — TestLimitNoEvict
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:287
|
||||
public async Task LimitNoEvict_StoreFullThrowsOnNewKey()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 2,
|
||||
evictOnLimit: false,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key1 = NextKey();
|
||||
var key2 = NextKey();
|
||||
var key3 = NextKey();
|
||||
|
||||
CreateTestAccount(store, key1, 100);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
|
||||
// key2 expires in 1 second
|
||||
CreateTestAccount(store, key2, 1);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
var hashBefore = store.Hash();
|
||||
|
||||
// Attempting to add key3 should throw (limit=2, no evict).
|
||||
var exp3 = DateTimeOffset.UtcNow.AddSeconds(100).ToUnixTimeSeconds();
|
||||
var jwt3 = MakeFakeJwt(exp3);
|
||||
Should.Throw<InvalidOperationException>(() => store.SaveAcc(key3, jwt3));
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Hash should not change after the failed add.
|
||||
store.Hash().ShouldBe(hashBefore);
|
||||
|
||||
// Wait for key2 to expire.
|
||||
await Task.Delay(2200);
|
||||
|
||||
// Now adding key3 should succeed.
|
||||
store.SaveAcc(key3, jwt3);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:288 — TestLruLoad
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:288
|
||||
public void LruLoad_LoadReordersLru()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key1 = NextKey();
|
||||
var key2 = NextKey();
|
||||
var key3 = NextKey();
|
||||
|
||||
CreateTestAccount(store, key1, 10);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
CreateTestAccount(store, key2, 10);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Access key1 — makes it the most-recently-used.
|
||||
store.LoadAcc(key1);
|
||||
|
||||
// Adding key3 should evict key2 (oldest), not key1.
|
||||
CreateTestAccount(store, key3, 10);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:289 — TestLruVolume
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:289
|
||||
public void LruVolume_ContinuousReplacementsAlwaysEvictsOldest()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
const int ReplaceCnt = 200; // must be > 2 due to the invariant
|
||||
var keys = new string[ReplaceCnt];
|
||||
|
||||
keys[0] = NextKey();
|
||||
CreateTestAccount(store, keys[0], 10000);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
|
||||
keys[1] = NextKey();
|
||||
CreateTestAccount(store, keys[1], 10000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
for (var i = 2; i < ReplaceCnt; i++)
|
||||
{
|
||||
keys[i] = NextKey();
|
||||
CreateTestAccount(store, keys[i], 10000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// key two positions back should have been evicted.
|
||||
File.Exists(Path.Combine(dir, keys[i - 2] + ".jwt")).ShouldBeFalse(
|
||||
$"key[{i - 2}] should be evicted after adding key[{i}]");
|
||||
// key one position back should still be present.
|
||||
File.Exists(Path.Combine(dir, keys[i - 1] + ".jwt")).ShouldBeTrue();
|
||||
// current key should be present.
|
||||
File.Exists(Path.Combine(dir, keys[i] + ".jwt")).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:290 — TestLru
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:290
|
||||
public async Task Lru_EvictsAndExpires()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key1 = NextKey();
|
||||
var key2 = NextKey();
|
||||
var key3 = NextKey();
|
||||
|
||||
CreateTestAccount(store, key1, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
CreateTestAccount(store, key2, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Adding key3 should evict key1 (oldest).
|
||||
CreateTestAccount(store, key3, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||||
|
||||
// Update key2 → moves it to MRU. key3 becomes LRU.
|
||||
CreateTestAccount(store, key2, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
|
||||
// Recreate key1 (which was evicted) → evicts key3.
|
||||
CreateTestAccount(store, key1, 1); // expires in 1 s
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Let key1 expire (1 s + 1 s buffer for rounding).
|
||||
await Task.Delay(2200);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
|
||||
|
||||
// Recreate key3 — no eviction needed, slot is free.
|
||||
CreateTestAccount(store, key3, 1000);
|
||||
CountJwtFiles(dir).ShouldBe(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:291 — TestReload
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:291
|
||||
public void Reload_DetectsFilesAddedAndRemoved()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
var notificationChan = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||||
limit: 2,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: pk => notificationChan.Enqueue(pk));
|
||||
|
||||
CountJwtFiles(dir).ShouldBe(0);
|
||||
var emptyHash = new byte[32];
|
||||
store.Hash().ShouldBe(emptyHash);
|
||||
|
||||
var files = new List<string>();
|
||||
|
||||
// Add 5 accounts by writing to disk directly, then Reload().
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var key = NextKey();
|
||||
var exp = DateTimeOffset.UtcNow.AddSeconds(10000).ToUnixTimeSeconds();
|
||||
var jwt = MakeFakeJwt(exp);
|
||||
var path = Path.Combine(dir, key + ".jwt");
|
||||
File.WriteAllText(path, jwt);
|
||||
files.Add(path);
|
||||
|
||||
store.Reload();
|
||||
|
||||
// Wait briefly for notification.
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(500);
|
||||
while (notificationChan.IsEmpty && DateTime.UtcNow < deadline)
|
||||
Thread.Sleep(10);
|
||||
notificationChan.TryDequeue(out _);
|
||||
|
||||
CountJwtFiles(dir).ShouldBe(i + 1);
|
||||
store.Hash().ShouldNotBe(emptyHash);
|
||||
|
||||
var packed = store.Pack(-1);
|
||||
packed.Split('\n').Length.ShouldBe(i + 1);
|
||||
}
|
||||
|
||||
// Now remove files one by one.
|
||||
foreach (var f in files)
|
||||
{
|
||||
var hash = store.Hash();
|
||||
hash.ShouldNotBe(emptyHash);
|
||||
File.Delete(f);
|
||||
store.Reload();
|
||||
CountJwtFiles(dir).ShouldBe(files.Count - files.IndexOf(f) - 1);
|
||||
}
|
||||
|
||||
store.Hash().ShouldBe(emptyHash);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:292 — TestExpirationUpdate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:292
|
||||
public async Task ExpirationUpdate_UpdatingExpirationExtendsTTL()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key = NextKey();
|
||||
var h = store.Hash();
|
||||
|
||||
// Save account with no expiry.
|
||||
CreateTestAccount(store, key, 0);
|
||||
var nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(1); // should NOT have expired (no exp claim)
|
||||
|
||||
// Save same account with 2-second expiry.
|
||||
CreateTestAccount(store, key, 2);
|
||||
nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(1); // not expired yet
|
||||
|
||||
// Save with no expiry again — resets expiry on that account.
|
||||
CreateTestAccount(store, key, 0);
|
||||
nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
h = nh;
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(1); // still NOT expired
|
||||
|
||||
// Now save with 1-second expiry.
|
||||
CreateTestAccount(store, key, 1);
|
||||
nh = store.Hash();
|
||||
nh.ShouldNotBe(h);
|
||||
|
||||
await Task.Delay(1500);
|
||||
CountJwtFiles(dir).ShouldBe(0); // should be expired now
|
||||
|
||||
var empty = new byte[32];
|
||||
store.Hash().ShouldBe(empty);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:293 — TestTTL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:293
|
||||
public async Task TTL_AccessResetsExpirationOnStore()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
var key = NextKey();
|
||||
|
||||
// TTL = 200 ms. Each access (Load or Save) should reset expiry.
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.FromMilliseconds(200),
|
||||
changeNotification: null);
|
||||
|
||||
CreateTestAccount(store, key, 0);
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
|
||||
// Access every 110 ms — should prevent expiration.
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
await Task.Delay(110);
|
||||
store.LoadAcc(key); // TTL reset via Load
|
||||
CountJwtFiles(dir).ShouldBe(1);
|
||||
}
|
||||
|
||||
// Stop accessing — wait for expiration.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(3);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
if (CountJwtFiles(dir) == 0)
|
||||
return; // expired as expected
|
||||
}
|
||||
|
||||
Assert.Fail("JWT should have expired by now via TTL");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:294 — TestRemove
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:294
|
||||
public void Remove_RespectsDeleteType()
|
||||
{
|
||||
foreach (var (deleteType, expectedJwt, expectedDeleted) in new[]
|
||||
{
|
||||
(JwtDeleteType.HardDelete, 0, 0),
|
||||
(JwtDeleteType.RenameDeleted, 0, 1),
|
||||
(JwtDeleteType.NoDelete, 1, 0),
|
||||
})
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dir, shard: false, create: false,
|
||||
deleteType: deleteType,
|
||||
expireCheck: TimeSpan.Zero,
|
||||
limit: 10,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
|
||||
var key = NextKey();
|
||||
CreateTestAccount(store, key, 0);
|
||||
CountJwtFiles(dir).ShouldBe(1, $"deleteType={deleteType}: should have 1 jwt before delete");
|
||||
|
||||
// For HardDelete and RenameDeleted the store must allow Delete.
|
||||
// For NoDelete, Delete should throw.
|
||||
if (deleteType == JwtDeleteType.NoDelete)
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(() => store.Delete(key),
|
||||
$"deleteType={deleteType}: should throw on delete");
|
||||
}
|
||||
else
|
||||
{
|
||||
store.Delete(key);
|
||||
}
|
||||
|
||||
// Count .jwt files (not .jwt.deleted).
|
||||
var jwtFiles = Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
|
||||
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
|
||||
jwtFiles.ShouldBe(expectedJwt, $"deleteType={deleteType}: unexpected jwt count");
|
||||
|
||||
// Count .jwt.deleted files.
|
||||
var deletedFiles = Directory.GetFiles(dir, "*.jwt.deleted", SearchOption.AllDirectories).Length;
|
||||
deletedFiles.ShouldBe(expectedDeleted, $"deleteType={deleteType}: unexpected deleted count");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:295 — TestNotificationOnPack
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:295
|
||||
public void NotificationOnPack_MergeFiresChangedCallback()
|
||||
{
|
||||
// Pre-populate a store with 4 accounts, pack it, then Merge into new stores.
|
||||
// Each Merge should fire the change notification for every key.
|
||||
const int JwtCount = 4;
|
||||
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
|
||||
var dirPack = MakeTempDir();
|
||||
var keys = new string[JwtCount];
|
||||
var jwts = new string[JwtCount];
|
||||
|
||||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
using var packStore = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dirPack, shard: false, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: infDur,
|
||||
limit: 0,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: pk => notifications.Enqueue(pk));
|
||||
|
||||
for (var i = 0; i < JwtCount; i++)
|
||||
{
|
||||
keys[i] = NextKey();
|
||||
jwts[i] = MakeFakeJwt(0); // no expiry
|
||||
packStore.SaveAcc(keys[i], jwts[i]);
|
||||
}
|
||||
|
||||
// Drain initial notifications.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (notifications.Count < JwtCount && DateTime.UtcNow < deadline)
|
||||
Thread.Sleep(10);
|
||||
while (notifications.TryDequeue(out _)) { }
|
||||
|
||||
var msg = packStore.Pack(-1);
|
||||
var hash = packStore.Hash();
|
||||
|
||||
// Merge into new stores (sharded and unsharded).
|
||||
foreach (var shard in new[] { true, false, true, false })
|
||||
{
|
||||
var dirMerge = MakeTempDir();
|
||||
var mergeNotifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
using var mergeStore = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dirMerge, shard: shard, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: infDur,
|
||||
limit: 0,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: pk => mergeNotifications.Enqueue(pk));
|
||||
|
||||
mergeStore.Merge(msg);
|
||||
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
|
||||
|
||||
// Hashes must match.
|
||||
packStore.Hash().ShouldBe(hash);
|
||||
|
||||
// Wait for JwtCount notifications.
|
||||
deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (mergeNotifications.Count < JwtCount && DateTime.UtcNow < deadline)
|
||||
Thread.Sleep(10);
|
||||
mergeNotifications.Count.ShouldBeGreaterThanOrEqualTo(JwtCount);
|
||||
|
||||
// Double-merge should produce no extra file changes.
|
||||
while (mergeNotifications.TryDequeue(out _)) { }
|
||||
mergeStore.Merge(msg);
|
||||
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
|
||||
Thread.Sleep(50);
|
||||
mergeNotifications.IsEmpty.ShouldBeTrue("no new notifications on re-merge of identical JWTs");
|
||||
|
||||
msg = mergeStore.Pack(-1);
|
||||
}
|
||||
|
||||
// All original JWTs can still be loaded from the last pack.
|
||||
for (var i = 0; i < JwtCount; i++)
|
||||
{
|
||||
var found = msg.Contains(keys[i] + "|" + jwts[i]);
|
||||
found.ShouldBeTrue($"key {keys[i]} should be in packed message");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// T:296 — TestNotificationOnPackWalk
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact] // T:296
|
||||
public void NotificationOnPackWalk_PropagatesAcrossChainOfStores()
|
||||
{
|
||||
const int StoreCnt = 5;
|
||||
const int KeyCnt = 50;
|
||||
const int IterCnt = 4; // reduced from Go's 8 to keep test fast
|
||||
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
|
||||
|
||||
var stores = new DirJwtStore[StoreCnt];
|
||||
var dirs = new string[StoreCnt];
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < StoreCnt; i++)
|
||||
{
|
||||
dirs[i] = MakeTempDir();
|
||||
stores[i] = DirJwtStore.NewExpiringDirJwtStore(
|
||||
dirs[i], shard: true, create: false,
|
||||
deleteType: JwtDeleteType.NoDelete,
|
||||
expireCheck: infDur,
|
||||
limit: 0,
|
||||
evictOnLimit: true,
|
||||
ttl: TimeSpan.Zero,
|
||||
changeNotification: null);
|
||||
}
|
||||
|
||||
for (var iter = 0; iter < IterCnt; iter++)
|
||||
{
|
||||
// Fill store[0] with KeyCnt new accounts.
|
||||
for (var j = 0; j < KeyCnt; j++)
|
||||
{
|
||||
var k = NextKey();
|
||||
var jwt = MakeFakeJwt(0);
|
||||
stores[0].SaveAcc(k, jwt);
|
||||
}
|
||||
|
||||
// Propagate via PackWalk from store[n] → store[n+1].
|
||||
for (var j = 0; j < StoreCnt - 1; j++)
|
||||
{
|
||||
stores[j].PackWalk(3, partial => stores[j + 1].Merge(partial));
|
||||
}
|
||||
|
||||
// Verify all adjacent store hashes match.
|
||||
for (var j = 0; j < StoreCnt - 1; j++)
|
||||
{
|
||||
stores[j].Hash().ShouldBe(stores[j + 1].Hash(),
|
||||
$"stores[{j}] and stores[{j + 1}] should have matching hashes after iteration {iter}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Accounts;
|
||||
|
||||
public sealed class ResolverDefaultsOpsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolverDefaults_StartReloadClose_ShouldBeNoOps()
|
||||
{
|
||||
var resolver = new DummyResolver();
|
||||
|
||||
resolver.IsReadOnly().ShouldBeTrue();
|
||||
resolver.IsTrackingUpdate().ShouldBeFalse();
|
||||
|
||||
resolver.Start(new object());
|
||||
resolver.Reload();
|
||||
resolver.Close();
|
||||
|
||||
var jwt = await resolver.FetchAsync("A");
|
||||
jwt.ShouldBe("jwt");
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() => resolver.StoreAsync("A", "jwt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateLeafNodes_SubscriptionDelta_ShouldUpdateMaps()
|
||||
{
|
||||
var acc = new Account { Name = "A" };
|
||||
var sub = new Subscription
|
||||
{
|
||||
Subject = System.Text.Encoding.UTF8.GetBytes("foo"),
|
||||
Queue = System.Text.Encoding.UTF8.GetBytes("q"),
|
||||
Qw = 2,
|
||||
};
|
||||
|
||||
acc.UpdateLeafNodes(sub, 1);
|
||||
|
||||
var rm = (Dictionary<string, int>?)typeof(Account)
|
||||
.GetField("_rm", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(acc);
|
||||
rm.ShouldNotBeNull();
|
||||
rm!["foo"].ShouldBe(1);
|
||||
|
||||
var lqws = (Dictionary<string, int>?)typeof(Account)
|
||||
.GetField("_lqws", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(acc);
|
||||
lqws.ShouldNotBeNull();
|
||||
lqws!["foo q"].ShouldBe(2);
|
||||
}
|
||||
|
||||
private sealed class DummyResolver : ResolverDefaultsOps
|
||||
{
|
||||
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||
=> Task.FromResult("jwt");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
@@ -381,4 +382,68 @@ public class AuthHandlerTests
|
||||
{
|
||||
AuthHandler.ConnectionTypes.IsKnown(ct).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetAuthErrClosedState — Go test ID 153 (T:153)
|
||||
// Mirrors the closed-state logic exercised by TestAuthProxyRequired.
|
||||
// (The full Go test is server-dependent; this covers the pure unit subset.)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the proxy-required branch of TestAuthProxyRequired (T:153).
|
||||
/// </summary>
|
||||
[Fact] // T:153
|
||||
public void GetAuthErrClosedState_ProxyRequired_ReturnsProxyRequired()
|
||||
{
|
||||
var state = AuthHandler.GetAuthErrClosedState(new AuthProxyRequiredException());
|
||||
state.ShouldBe(ClosedState.ProxyRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_ProxyNotTrusted_ReturnsProxyNotTrusted()
|
||||
{
|
||||
var state = AuthHandler.GetAuthErrClosedState(new AuthProxyNotTrustedException());
|
||||
state.ShouldBe(ClosedState.ProxyNotTrusted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_OtherException_ReturnsAuthenticationViolation()
|
||||
{
|
||||
var state = AuthHandler.GetAuthErrClosedState(new InvalidOperationException("bad"));
|
||||
state.ShouldBe(ClosedState.AuthenticationViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_NullException_ReturnsAuthenticationViolation()
|
||||
{
|
||||
var state = AuthHandler.GetAuthErrClosedState(null);
|
||||
state.ShouldBe(ClosedState.AuthenticationViolation);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ValidateProxies
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateProxies_ProxyRequiredWithoutProxyProtocol_ReturnsError()
|
||||
{
|
||||
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false };
|
||||
var err = AuthHandler.ValidateProxies(opts);
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("proxy_required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateProxies_ProxyRequiredWithProxyProtocol_ReturnsNull()
|
||||
{
|
||||
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true };
|
||||
AuthHandler.ValidateProxies(opts).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateProxies_NeitherSet_ReturnsNull()
|
||||
{
|
||||
var opts = new ServerOptions();
|
||||
AuthHandler.ValidateProxies(opts).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
using ZB.MOM.NatsNet.Server.Auth;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
public class AuthHandlerExtendedTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidateProxies_ProxyRequiredWithoutProtocol_ReturnsError()
|
||||
{
|
||||
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false };
|
||||
var err = AuthHandler.ValidateProxies(opts);
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("proxy_required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateProxies_ProxyRequiredWithProtocol_ReturnsNull()
|
||||
{
|
||||
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true };
|
||||
var err = AuthHandler.ValidateProxies(opts);
|
||||
err.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_ProxyNotTrusted_ReturnsProxyNotTrusted()
|
||||
{
|
||||
var err = new AuthProxyNotTrustedException();
|
||||
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.ProxyNotTrusted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_ProxyRequired_ReturnsProxyRequired()
|
||||
{
|
||||
var err = new AuthProxyRequiredException();
|
||||
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.ProxyRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_OtherError_ReturnsAuthenticationViolation()
|
||||
{
|
||||
var err = new InvalidOperationException("bad credentials");
|
||||
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.AuthenticationViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAuthErrClosedState_NullError_ReturnsAuthenticationViolation()
|
||||
{
|
||||
AuthHandler.GetAuthErrClosedState(null).ShouldBe(ClosedState.AuthenticationViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckClientTlsCertSubject_NullCert_ReturnsFalse()
|
||||
{
|
||||
AuthHandler.CheckClientTlsCertSubject(null, _ => true).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUserPermissionsTemplate_ExpandsAccountVariable()
|
||||
{
|
||||
var lim = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = new List<string> { "{{account}}.events" } },
|
||||
};
|
||||
var (result, err) = AuthHandler.ProcessUserPermissionsTemplate(lim, "myaccount", null);
|
||||
err.ShouldBeNull();
|
||||
result.Publish!.Allow![0].ShouldBe("myaccount.events");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUserPermissionsTemplate_ExpandsTagVariable()
|
||||
{
|
||||
var lim = new Permissions
|
||||
{
|
||||
Subscribe = new SubjectPermission { Allow = new List<string> { "{{tag.region}}.alerts" } },
|
||||
};
|
||||
var tags = new Dictionary<string, string> { ["region"] = "us-east" };
|
||||
var (result, err) = AuthHandler.ProcessUserPermissionsTemplate(lim, "acc", tags);
|
||||
err.ShouldBeNull();
|
||||
result.Subscribe!.Allow![0].ShouldBe("us-east.alerts");
|
||||
}
|
||||
}
|
||||
|
||||
public class JwtProcessorOperatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadOperatorJwtInternal_EmptyString_ReturnsError()
|
||||
{
|
||||
var (claims, err) = JwtProcessor.ReadOperatorJwtInternal(string.Empty);
|
||||
claims.ShouldBeNull();
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOperatorJwtInternal_InvalidPrefix_ReturnsFormatError()
|
||||
{
|
||||
var (claims, err) = JwtProcessor.ReadOperatorJwtInternal("NOTAJWT.payload.sig");
|
||||
claims.ShouldBeNull();
|
||||
err.ShouldBeOfType<FormatException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOperatorJwt_FileNotFound_ReturnsError()
|
||||
{
|
||||
var (claims, err) = JwtProcessor.ReadOperatorJwt("/nonexistent/operator.jwt");
|
||||
claims.ShouldBeNull();
|
||||
err.ShouldBeOfType<IOException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTrustedOperators_EmptyList_ReturnsNull()
|
||||
{
|
||||
var opts = new ServerOptions();
|
||||
JwtProcessor.ValidateTrustedOperators(opts).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class JwtProcessorTests
|
||||
public void WipeSlice_FillsWithX()
|
||||
{
|
||||
var buf = new byte[] { 0x01, 0x02, 0x03 };
|
||||
JwtProcessor.WipeSlice(buf);
|
||||
AuthHandler.WipeSlice(buf);
|
||||
buf.ShouldAllBe(b => b == (byte)'x');
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public class JwtProcessorTests
|
||||
public void WipeSlice_EmptyBuffer_NoOp()
|
||||
{
|
||||
var buf = Array.Empty<byte>();
|
||||
JwtProcessor.WipeSlice(buf);
|
||||
AuthHandler.WipeSlice(buf);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
|
||||
|
||||
public sealed class OcspResponseCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void LocalDirCache_GetPutRemove_ShouldPersistToDisk()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"ocsp-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var cache = new LocalDirCache(dir);
|
||||
cache.Get("abc").ShouldBeNull();
|
||||
|
||||
cache.Put("abc", [1, 2, 3]);
|
||||
cache.Get("abc").ShouldBe([1, 2, 3]);
|
||||
|
||||
cache.Remove("abc");
|
||||
cache.Get("abc").ShouldBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoOpCache_AndMonitor_ShouldNoOpSafely()
|
||||
{
|
||||
var noOp = new NoOpCache();
|
||||
noOp.Put("k", [5]);
|
||||
noOp.Get("k").ShouldBeNull();
|
||||
noOp.Remove("k");
|
||||
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"ocsp-monitor-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var stapleFile = Path.Combine(dir, "staple.bin");
|
||||
File.WriteAllBytes(stapleFile, [9, 9]);
|
||||
|
||||
var monitor = new OcspMonitor
|
||||
{
|
||||
OcspStapleFile = stapleFile,
|
||||
CheckInterval = TimeSpan.FromMilliseconds(10),
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
Thread.Sleep(30);
|
||||
monitor.GetStaple().ShouldBe([9, 9]);
|
||||
monitor.Stop();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests;
|
||||
|
||||
public sealed class ClientConnectionStubFeaturesTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProcessConnect_ProcessPong_AndTimers_ShouldBehave()
|
||||
{
|
||||
var (server, err) = NatsServer.NewServer(new ServerOptions
|
||||
{
|
||||
PingInterval = TimeSpan.FromMilliseconds(20),
|
||||
AuthTimeout = 0.1,
|
||||
});
|
||||
err.ShouldBeNull();
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
var c = new ClientConnection(ClientKind.Client, server, ms)
|
||||
{
|
||||
Cid = 9,
|
||||
Trace = true,
|
||||
};
|
||||
|
||||
var connectJson = Encoding.UTF8.GetBytes("{\"echo\":false,\"headers\":true,\"name\":\"unit\"}");
|
||||
c.ProcessConnect(connectJson);
|
||||
c.Opts.Name.ShouldBe("unit");
|
||||
c.Echo.ShouldBeFalse();
|
||||
c.Headers.ShouldBeTrue();
|
||||
|
||||
c.RttStart = DateTime.UtcNow - TimeSpan.FromMilliseconds(50);
|
||||
c.ProcessPong();
|
||||
c.GetRttValue().ShouldBeGreaterThan(TimeSpan.Zero);
|
||||
|
||||
c.SetPingTimer();
|
||||
GetTimer(c, "_pingTimer").ShouldNotBeNull();
|
||||
|
||||
c.SetAuthTimer(TimeSpan.FromMilliseconds(20));
|
||||
GetTimer(c, "_atmr").ShouldNotBeNull();
|
||||
|
||||
c.TraceMsg(Encoding.UTF8.GetBytes("MSG"));
|
||||
c.FlushSignal();
|
||||
c.UpdateS2AutoCompressionLevel();
|
||||
|
||||
c.SetExpirationTimer(TimeSpan.Zero);
|
||||
c.IsClosed().ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static Timer? GetTimer(ClientConnection c, string field)
|
||||
{
|
||||
return (Timer?)typeof(ClientConnection)
|
||||
.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Config;
|
||||
|
||||
using System.Security.Authentication;
|
||||
using ZB.MOM.NatsNet.Server.Config;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
public class ServerOptionsConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProcessConfigString_MinimalJson_SetsPort()
|
||||
{
|
||||
var opts = ServerOptionsConfiguration.ProcessConfigString("""{"port": 4222}""");
|
||||
opts.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigString_EmptyJson_AppliesDefaults()
|
||||
{
|
||||
var opts = ServerOptionsConfiguration.ProcessConfigString("{}");
|
||||
opts.Port.ShouldBe(ServerConstants.DefaultPort);
|
||||
opts.Host.ShouldBe(ServerConstants.DefaultHost);
|
||||
opts.MaxPayload.ShouldBe(ServerConstants.MaxPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigString_AllBasicFields_Roundtrip()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"port": 5222,
|
||||
"host": "127.0.0.1",
|
||||
"server_name": "test-server",
|
||||
"debug": true,
|
||||
"trace": true,
|
||||
"max_connections": 100,
|
||||
"auth_timeout": 2.0
|
||||
}
|
||||
""";
|
||||
var opts = ServerOptionsConfiguration.ProcessConfigString(json);
|
||||
opts.Port.ShouldBe(5222);
|
||||
opts.Host.ShouldBe("127.0.0.1");
|
||||
opts.ServerName.ShouldBe("test-server");
|
||||
opts.Debug.ShouldBeTrue();
|
||||
opts.Trace.ShouldBeTrue();
|
||||
opts.MaxConn.ShouldBe(100);
|
||||
opts.AuthTimeout.ShouldBe(2.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigFile_FileNotFound_Throws()
|
||||
{
|
||||
Should.Throw<FileNotFoundException>(() =>
|
||||
ServerOptionsConfiguration.ProcessConfigFile("/nonexistent/path.json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigFile_ValidFile_ReturnsOptions()
|
||||
{
|
||||
var tmpFile = Path.GetTempFileName();
|
||||
File.WriteAllText(tmpFile, """{"port": 9090, "server_name": "from-file"}""");
|
||||
try
|
||||
{
|
||||
var opts = ServerOptionsConfiguration.ProcessConfigFile(tmpFile);
|
||||
opts.Port.ShouldBe(9090);
|
||||
opts.ServerName.ShouldBe("from-file");
|
||||
}
|
||||
finally { File.Delete(tmpFile); }
|
||||
}
|
||||
}
|
||||
|
||||
public class NatsDurationJsonConverterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("2s", 0, 0, 2, 0)]
|
||||
[InlineData("100ms", 0, 0, 0, 100)]
|
||||
[InlineData("1h30m", 1, 30, 0, 0)]
|
||||
public void Parse_ValidDurationStrings_ReturnsCorrectTimeSpan(
|
||||
string input, int hours, int minutes, int seconds, int ms)
|
||||
{
|
||||
var expected = new TimeSpan(0, hours, minutes, seconds, ms);
|
||||
NatsDurationJsonConverter.Parse(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_FiveMinutesTenSeconds_ReturnsCorrectSpan()
|
||||
{
|
||||
var result = NatsDurationJsonConverter.Parse("5m10s");
|
||||
result.ShouldBe(TimeSpan.FromSeconds(310));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidString_ThrowsFormatException()
|
||||
{
|
||||
Should.Throw<FormatException>(() => NatsDurationJsonConverter.Parse("notaduration"));
|
||||
}
|
||||
}
|
||||
|
||||
public class StorageSizeJsonConverterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1GB", 1L * 1024 * 1024 * 1024)]
|
||||
[InlineData("512MB", 512L * 1024 * 1024)]
|
||||
[InlineData("1KB", 1024L)]
|
||||
[InlineData("1024", 1024L)]
|
||||
public void Parse_ValidSizeStrings_ReturnsBytes(string input, long expectedBytes)
|
||||
{
|
||||
StorageSizeJsonConverter.Parse(input).ShouldBe(expectedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public class NatsUrlJsonConverterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("nats://localhost:4222", "nats://localhost:4222")]
|
||||
[InlineData("localhost:4222", "nats://localhost:4222")]
|
||||
[InlineData("localhost", "nats://localhost")]
|
||||
public void Normalise_ValidUrls_NormalisesCorrectly(string input, string expected)
|
||||
{
|
||||
NatsUrlJsonConverter.Normalise(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalise_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
NatsUrlJsonConverter.Normalise(string.Empty).ShouldBe(string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public class TlsVersionJsonConverterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.2", SslProtocols.Tls12)]
|
||||
[InlineData("TLS12", SslProtocols.Tls12)]
|
||||
[InlineData("1.3", SslProtocols.Tls13)]
|
||||
[InlineData("TLS13", SslProtocols.Tls13)]
|
||||
public void Parse_ValidVersionStrings_ReturnsCorrectProtocol(string input, SslProtocols expected)
|
||||
{
|
||||
TlsVersionJsonConverter.Parse(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidVersion_ThrowsFormatException()
|
||||
{
|
||||
Should.Throw<FormatException>(() => TlsVersionJsonConverter.Parse("2.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for server logging trace sanitization (RemovePassFromTrace, RemoveAuthTokenFromTrace).
|
||||
/// Mirrors server/log_test.go — TestNoPasswordsFromConnectTrace, TestRemovePassFromTrace,
|
||||
/// TestRemoveAuthTokenFromTrace.
|
||||
/// </summary>
|
||||
public class ServerLoggerTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// T:2020 — TestNoPasswordsFromConnectTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestNoPasswordsFromConnectTrace.
|
||||
/// Verifies that a CONNECT trace with a password or auth_token does not
|
||||
/// expose the secret value after sanitization.
|
||||
/// </summary>
|
||||
[Fact] // T:2020
|
||||
public void NoPasswordsFromConnectTrace_ShouldSucceed()
|
||||
{
|
||||
const string connectWithPass =
|
||||
"""CONNECT {"verbose":false,"pedantic":false,"user":"derek","pass":"s3cr3t","tls_required":false}""";
|
||||
const string connectWithToken =
|
||||
"""CONNECT {"verbose":false,"auth_token":"secret-token","tls_required":false}""";
|
||||
|
||||
ServerLogging.RemovePassFromTrace(connectWithPass).ShouldNotContain("s3cr3t");
|
||||
ServerLogging.RemoveAuthTokenFromTrace(connectWithToken).ShouldNotContain("secret-token");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T:2021 — TestRemovePassFromTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestRemovePassFromTrace — covers all test vectors from log_test.go.
|
||||
/// Each case verifies that RemovePassFromTrace redacts the first pass/password value
|
||||
/// with [REDACTED] while leaving other fields intact.
|
||||
/// </summary>
|
||||
[Theory] // T:2021
|
||||
[InlineData(
|
||||
"user and pass",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\":\"s3cr3t\"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and pass extra space",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\": \"s3cr3t\"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\": \"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and pass is empty",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\":\"\"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and pass is empty whitespace",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\":\" \"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"pass\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"only pass",
|
||||
"CONNECT {\"pass\":\"s3cr3t\",}\r\n",
|
||||
"CONNECT {\"pass\":\"[REDACTED]\",}\r\n")]
|
||||
[InlineData(
|
||||
"complete connect",
|
||||
"CONNECT {\"echo\":true,\"verbose\":false,\"pedantic\":false,\"user\":\"foo\",\"pass\":\"s3cr3t\",\"tls_required\":false,\"name\":\"APM7JU94z77YzP6WTBEiuw\"}\r\n",
|
||||
"CONNECT {\"echo\":true,\"verbose\":false,\"pedantic\":false,\"user\":\"foo\",\"pass\":\"[REDACTED]\",\"tls_required\":false,\"name\":\"APM7JU94z77YzP6WTBEiuw\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and pass are filtered",
|
||||
"CONNECT {\"user\":\"s3cr3t\",\"pass\":\"s3cr3t\"}\r\n",
|
||||
"CONNECT {\"user\":\"s3cr3t\",\"pass\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"single long password",
|
||||
"CONNECT {\"pass\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}\r\n",
|
||||
"CONNECT {\"pass\":\"[REDACTED]\"}\r\n")]
|
||||
public void RemovePassFromTrace_ShouldSucceed(string name, string input, string expected)
|
||||
{
|
||||
_ = name; // used for test display only
|
||||
ServerLogging.RemovePassFromTrace(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T:2022 — TestRemoveAuthTokenFromTrace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestRemoveAuthTokenFromTrace — covers representative test vectors
|
||||
/// from log_test.go. Each case verifies that RemoveAuthTokenFromTrace redacts
|
||||
/// the first auth_token value with [REDACTED].
|
||||
/// </summary>
|
||||
[Theory] // T:2022
|
||||
[InlineData(
|
||||
"user and auth_token",
|
||||
"CONNECT {\"user\":\"derek\",\"auth_token\":\"s3cr3t\"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"auth_token\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and auth_token extra space",
|
||||
"CONNECT {\"user\":\"derek\",\"auth_token\": \"s3cr3t\"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"auth_token\": \"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and auth_token is empty",
|
||||
"CONNECT {\"user\":\"derek\",\"auth_token\":\"\"}\r\n",
|
||||
"CONNECT {\"user\":\"derek\",\"auth_token\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"only auth_token",
|
||||
"CONNECT {\"auth_token\":\"s3cr3t\",}\r\n",
|
||||
"CONNECT {\"auth_token\":\"[REDACTED]\",}\r\n")]
|
||||
[InlineData(
|
||||
"complete connect",
|
||||
"CONNECT {\"echo\":true,\"verbose\":false,\"pedantic\":false,\"auth_token\":\"s3cr3t\",\"tls_required\":false,\"name\":\"APM7JU94z77YzP6WTBEiuw\"}\r\n",
|
||||
"CONNECT {\"echo\":true,\"verbose\":false,\"pedantic\":false,\"auth_token\":\"[REDACTED]\",\"tls_required\":false,\"name\":\"APM7JU94z77YzP6WTBEiuw\"}\r\n")]
|
||||
[InlineData(
|
||||
"user and token are filtered",
|
||||
"CONNECT {\"user\":\"s3cr3t\",\"auth_token\":\"s3cr3t\"}\r\n",
|
||||
"CONNECT {\"user\":\"s3cr3t\",\"auth_token\":\"[REDACTED]\"}\r\n")]
|
||||
[InlineData(
|
||||
"single long token",
|
||||
"CONNECT {\"auth_token\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}\r\n",
|
||||
"CONNECT {\"auth_token\":\"[REDACTED]\"}\r\n")]
|
||||
public void RemoveAuthTokenFromTrace_ShouldSucceed(string name, string input, string expected)
|
||||
{
|
||||
_ = name; // used for test display only
|
||||
ServerLogging.RemoveAuthTokenFromTrace(input).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
@@ -8,13 +8,22 @@ using ZB.MOM.NatsNet.Server.Internal;
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SignalHandler — mirrors tests from server/signal_test.go.
|
||||
/// Tests for SignalHandler — mirrors server/signal_test.go.
|
||||
/// </summary>
|
||||
public class SignalHandlerTests
|
||||
public sealed class SignalHandlerTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Mirrors CommandToSignal mapping tests.
|
||||
/// </summary>
|
||||
public SignalHandlerTests()
|
||||
{
|
||||
SignalHandler.ResetTestHooks();
|
||||
SignalHandler.SetProcessName("nats-server");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SignalHandler.ResetTestHooks();
|
||||
SignalHandler.SetProcessName("nats-server");
|
||||
}
|
||||
|
||||
[Fact] // T:3158
|
||||
public void CommandToUnixSignal_ShouldMapCorrectly()
|
||||
{
|
||||
@@ -22,31 +31,25 @@ public class SignalHandlerTests
|
||||
SignalHandler.CommandToUnixSignal(ServerCommand.Quit).ShouldBe(UnixSignal.SigInt);
|
||||
SignalHandler.CommandToUnixSignal(ServerCommand.Reopen).ShouldBe(UnixSignal.SigUsr1);
|
||||
SignalHandler.CommandToUnixSignal(ServerCommand.Reload).ShouldBe(UnixSignal.SigHup);
|
||||
SignalHandler.CommandToUnixSignal(ServerCommand.Term).ShouldBe(UnixSignal.SigTerm);
|
||||
SignalHandler.CommandToUnixSignal(ServerCommand.LameDuckMode).ShouldBe(UnixSignal.SigUsr2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors SetProcessName test.
|
||||
/// </summary>
|
||||
[Fact] // T:3155
|
||||
public void SetProcessName_ShouldNotThrow()
|
||||
{
|
||||
Should.NotThrow(() => SignalHandler.SetProcessName("test-server"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify IsWindowsService returns false on non-Windows.
|
||||
/// </summary>
|
||||
[Fact] // T:3149
|
||||
public void IsWindowsService_ShouldReturnFalse()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return; // Skip on Windows
|
||||
return;
|
||||
|
||||
SignalHandler.IsWindowsService().ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors Run — service.go Run() simply invokes the start function.
|
||||
/// </summary>
|
||||
[Fact] // T:3148
|
||||
public void Run_ShouldInvokeStartAction()
|
||||
{
|
||||
@@ -55,16 +58,198 @@ public class SignalHandlerTests
|
||||
called.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ProcessSignal with invalid PID expression should return error.
|
||||
/// </summary>
|
||||
[Fact] // T:3157
|
||||
public void ProcessSignal_InvalidPid_ShouldReturnError()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return; // Skip on Windows
|
||||
return;
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "not-a-pid");
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact] // T:2919
|
||||
public void ProcessSignalInvalidCommand_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
var err = SignalHandler.ProcessSignal((ServerCommand)99, "123");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldContain("unknown signal");
|
||||
}
|
||||
|
||||
[Fact] // T:2920
|
||||
public void ProcessSignalInvalidPid_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "abc");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldBe("invalid pid: abc");
|
||||
}
|
||||
|
||||
[Fact] // T:2913
|
||||
public void ProcessSignalMultipleProcesses_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
SignalHandler.ResolvePidsHandler = () => [123, 456];
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldBe("multiple nats-server processes running:\n123\n456");
|
||||
}
|
||||
|
||||
[Fact] // T:2914
|
||||
public void ProcessSignalMultipleProcessesGlob_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
SignalHandler.ResolvePidsHandler = () => [123, 456];
|
||||
SignalHandler.SendSignalHandler = static (_, _) => new InvalidOperationException("mock");
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "*");
|
||||
err.ShouldNotBeNull();
|
||||
var lines = err!.Message.Split('\n');
|
||||
lines.Length.ShouldBe(3);
|
||||
lines[0].ShouldBe(string.Empty);
|
||||
lines[1].ShouldStartWith("signal \"stop\" 123:");
|
||||
lines[2].ShouldStartWith("signal \"stop\" 456:");
|
||||
}
|
||||
|
||||
[Fact] // T:2915
|
||||
public void ProcessSignalMultipleProcessesGlobPartial_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
SignalHandler.ResolvePidsHandler = () => [123, 124, 456];
|
||||
SignalHandler.SendSignalHandler = static (_, _) => new InvalidOperationException("mock");
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "12*");
|
||||
err.ShouldNotBeNull();
|
||||
var lines = err!.Message.Split('\n');
|
||||
lines.Length.ShouldBe(3);
|
||||
lines[0].ShouldBe(string.Empty);
|
||||
lines[1].ShouldStartWith("signal \"stop\" 123:");
|
||||
lines[2].ShouldStartWith("signal \"stop\" 124:");
|
||||
}
|
||||
|
||||
[Fact] // T:2916
|
||||
public void ProcessSignalPgrepError_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
SignalHandler.ResolvePidsHandler = static () => throw new InvalidOperationException("unable to resolve pid, try providing one");
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldBe("unable to resolve pid, try providing one");
|
||||
}
|
||||
|
||||
[Fact] // T:2917
|
||||
public void ProcessSignalPgrepMangled_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
SignalHandler.ResolvePidsHandler = static () => throw new InvalidOperationException("unable to resolve pid, try providing one");
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "");
|
||||
err.ShouldNotBeNull();
|
||||
err!.Message.ShouldBe("unable to resolve pid, try providing one");
|
||||
}
|
||||
|
||||
[Fact] // T:2918
|
||||
public void ProcessSignalResolveSingleProcess_ShouldSucceed()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
var called = false;
|
||||
SignalHandler.ResolvePidsHandler = () => [123];
|
||||
SignalHandler.SendSignalHandler = (pid, signal) =>
|
||||
{
|
||||
called = true;
|
||||
pid.ShouldBe(123);
|
||||
signal.ShouldBe(UnixSignal.SigKill);
|
||||
return null;
|
||||
};
|
||||
|
||||
var err = SignalHandler.ProcessSignal(ServerCommand.Stop, "");
|
||||
err.ShouldBeNull();
|
||||
called.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact] // T:2921
|
||||
public void ProcessSignalQuitProcess_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Quit, UnixSignal.SigInt, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2922
|
||||
public void ProcessSignalTermProcess_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Term, UnixSignal.SigTerm, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2923
|
||||
public void ProcessSignalReopenProcess_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Reopen, UnixSignal.SigUsr1, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2924
|
||||
public void ProcessSignalReloadProcess_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Reload, UnixSignal.SigHup, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2925
|
||||
public void ProcessSignalLameDuckMode_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.LameDuckMode, UnixSignal.SigUsr2, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2926
|
||||
public void ProcessSignalTermDuringLameDuckMode_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Term, UnixSignal.SigTerm, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2927
|
||||
public void SignalInterruptHasSuccessfulExit_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Quit, UnixSignal.SigInt, "123");
|
||||
}
|
||||
|
||||
[Fact] // T:2928
|
||||
public void SignalTermHasSuccessfulExit_ShouldSucceed()
|
||||
{
|
||||
ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand.Term, UnixSignal.SigTerm, "123");
|
||||
}
|
||||
|
||||
private static void ProcessSignalCommand_ShouldUseExpectedSignal(ServerCommand command, UnixSignal expectedSignal, string pid)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
var called = false;
|
||||
SignalHandler.SendSignalHandler = (resolvedPid, signal) =>
|
||||
{
|
||||
called = true;
|
||||
resolvedPid.ShouldBe(123);
|
||||
signal.ShouldBe(expectedSignal);
|
||||
return null;
|
||||
};
|
||||
|
||||
var err = SignalHandler.ProcessSignal(command, pid);
|
||||
err.ShouldBeNull();
|
||||
called.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
public sealed class CompressionInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void MarshalMetadata_UnmarshalMetadata_ShouldRoundTrip()
|
||||
{
|
||||
var ci = new CompressionInfo
|
||||
{
|
||||
Type = StoreCompression.S2Compression,
|
||||
Original = 12345,
|
||||
Compressed = 6789,
|
||||
};
|
||||
|
||||
var payload = ci.MarshalMetadata();
|
||||
payload.Length.ShouldBeGreaterThan(4);
|
||||
|
||||
var copy = new CompressionInfo();
|
||||
var consumed = copy.UnmarshalMetadata(payload);
|
||||
|
||||
consumed.ShouldBe(payload.Length);
|
||||
copy.Type.ShouldBe(StoreCompression.S2Compression);
|
||||
copy.Original.ShouldBe(12345UL);
|
||||
copy.Compressed.ShouldBe(6789UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmarshalMetadata_InvalidPrefix_ShouldReturnZero()
|
||||
{
|
||||
var ci = new CompressionInfo();
|
||||
ci.UnmarshalMetadata([1, 2, 3, 4]).ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
public sealed class ConsumerFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpdateDelivered_UpdateAcks_AndReload_ShouldPersistState()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"cfs-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var fs = NewStore(root);
|
||||
var cfg = new ConsumerConfig { Durable = "D", AckPolicy = AckPolicy.AckExplicit };
|
||||
var cs = (ConsumerFileStore)fs.ConsumerStore("D", DateTime.UtcNow, cfg);
|
||||
|
||||
cs.SetStarting(0);
|
||||
cs.UpdateDelivered(1, 1, 1, 123);
|
||||
cs.UpdateDelivered(2, 2, 1, 456);
|
||||
cs.UpdateAcks(1, 1);
|
||||
|
||||
var (state, err) = cs.State();
|
||||
err.ShouldBeNull();
|
||||
state.ShouldNotBeNull();
|
||||
state!.Delivered.Consumer.ShouldBe(2UL);
|
||||
state.AckFloor.Consumer.ShouldBe(1UL);
|
||||
|
||||
cs.Stop();
|
||||
|
||||
var odir = Path.Combine(root, FileStoreDefaults.ConsumerDir, "D");
|
||||
var loaded = new ConsumerFileStore(
|
||||
fs,
|
||||
new FileConsumerInfo { Name = "D", Created = DateTime.UtcNow, Config = cfg },
|
||||
"D",
|
||||
odir);
|
||||
|
||||
var (loadedState, loadedErr) = loaded.State();
|
||||
loadedErr.ShouldBeNull();
|
||||
loadedState.ShouldNotBeNull();
|
||||
loadedState!.Delivered.Consumer.ShouldBe(2UL);
|
||||
loadedState.AckFloor.Consumer.ShouldBe(1UL);
|
||||
|
||||
loaded.Delete();
|
||||
Directory.Exists(odir).ShouldBeFalse();
|
||||
fs.Stop();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static JetStreamFileStore NewStore(string root)
|
||||
{
|
||||
return new JetStreamFileStore(
|
||||
new FileStoreConfig { StoreDir = root },
|
||||
new FileStreamInfo
|
||||
{
|
||||
Created = DateTime.UtcNow,
|
||||
Config = new StreamConfig
|
||||
{
|
||||
Name = "S",
|
||||
Storage = StorageType.FileStorage,
|
||||
Subjects = ["foo"],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Mirrors server/jetstream_batching_test.go in the NATS server Go source.
|
||||
// ALL tests in this file are deferred: they all use createJetStreamClusterExplicit()
|
||||
// or RunBasicJetStreamServer() and require a running JetStream cluster/server.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for JetStream atomic batch publishing.
|
||||
/// Mirrors server/jetstream_batching_test.go.
|
||||
/// All tests are deferred pending JetStream server infrastructure.
|
||||
/// </summary>
|
||||
public sealed class JetStreamBatchingTests
|
||||
{
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:716
|
||||
public void JetStreamAtomicBatchPublish_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:717
|
||||
public void JetStreamAtomicBatchPublishEmptyAck_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:718
|
||||
public void JetStreamAtomicBatchPublishCommitEob_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:719
|
||||
public void JetStreamAtomicBatchPublishLimits_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:720
|
||||
public void JetStreamAtomicBatchPublishDedupeNotAllowed_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:721
|
||||
public void JetStreamAtomicBatchPublishSourceAndMirror_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:722
|
||||
public void JetStreamAtomicBatchPublishCleanup_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:723
|
||||
public void JetStreamAtomicBatchPublishConfigOpts_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:724
|
||||
public void JetStreamAtomicBatchPublishDenyHeaders_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:725
|
||||
public void JetStreamAtomicBatchPublishStageAndCommit_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:726
|
||||
public void JetStreamAtomicBatchPublishHighLevelRollback_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:727
|
||||
public void JetStreamAtomicBatchPublishExpectedPerSubject_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:728
|
||||
public void JetStreamAtomicBatchPublishSingleServerRecovery_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:729
|
||||
public void JetStreamAtomicBatchPublishSingleServerRecoveryCommitEob_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:730
|
||||
public void JetStreamAtomicBatchPublishEncode_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:731
|
||||
public void JetStreamAtomicBatchPublishProposeOne_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:732
|
||||
public void JetStreamAtomicBatchPublishProposeMultiple_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:733
|
||||
public void JetStreamAtomicBatchPublishProposeOnePartialBatch_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:734
|
||||
public void JetStreamAtomicBatchPublishProposeMultiplePartialBatches_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:735
|
||||
public void JetStreamAtomicBatchPublishContinuousBatchesStillMoveAppliedUp_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:736
|
||||
public void JetStreamAtomicBatchPublishPartiallyAppliedBatchOnRecovery_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:737
|
||||
public void JetStreamRollupIsolatedRead_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:738
|
||||
public void JetStreamAtomicBatchPublishAdvisories_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:739
|
||||
public void JetStreamAtomicBatchPublishExpectedSeq_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:740
|
||||
public void JetStreamAtomicBatchPublishPartialBatchInSharedAppendEntry_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:741
|
||||
public void JetStreamAtomicBatchPublishRejectPartialBatchOnLeaderChange_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:742
|
||||
public void JetStreamAtomicBatchPublishPersistModeAsync_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:743
|
||||
public void JetStreamAtomicBatchPublishExpectedLastSubjectSequence_RequiresRunningServer() { }
|
||||
|
||||
[Fact(Skip = "deferred: requires running JetStream cluster")] // T:744
|
||||
public void JetStreamAtomicBatchPublishCommitUnsupported_RequiresRunningServer() { }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
using Shouldly;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for JetStream API error helpers.
|
||||
/// Mirrors server/jetstream_errors_test.go.
|
||||
/// </summary>
|
||||
public sealed class JetStreamErrorsTests
|
||||
{
|
||||
[Fact] // T:1381
|
||||
public void IsNatsErr_ShouldSucceed()
|
||||
{
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NotEnabledForAccount,
|
||||
JsApiErrors.NotEnabledForAccount.ErrCode).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NotEnabledForAccount,
|
||||
JsApiErrors.ClusterNotActive.ErrCode).ShouldBeFalse();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NotEnabledForAccount,
|
||||
JsApiErrors.ClusterNotActive.ErrCode,
|
||||
JsApiErrors.ClusterNotAvail.ErrCode).ShouldBeFalse();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NotEnabledForAccount,
|
||||
JsApiErrors.ClusterNotActive.ErrCode,
|
||||
JsApiErrors.NotEnabledForAccount.ErrCode).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
new JsApiError { ErrCode = JsApiErrors.NotEnabledForAccount.ErrCode },
|
||||
1,
|
||||
JsApiErrors.ClusterNotActive.ErrCode,
|
||||
JsApiErrors.NotEnabledForAccount.ErrCode).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
new JsApiError { ErrCode = JsApiErrors.NotEnabledForAccount.ErrCode },
|
||||
1,
|
||||
2,
|
||||
JsApiErrors.ClusterNotActive.ErrCode).ShouldBeFalse();
|
||||
|
||||
JsApiErrors.IsNatsErr(null, JsApiErrors.ClusterNotActive.ErrCode).ShouldBeFalse();
|
||||
JsApiErrors.IsNatsErr(new InvalidOperationException("x"), JsApiErrors.ClusterNotActive.ErrCode).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact] // T:1382
|
||||
public void ApiError_Error_ShouldSucceed()
|
||||
{
|
||||
JsApiErrors.Error(JsApiErrors.ClusterNotActive).ShouldBe("JetStream not in clustered mode (10006)");
|
||||
}
|
||||
|
||||
[Fact] // T:1383
|
||||
public void ApiError_NewWithTags_ShouldSucceed()
|
||||
{
|
||||
var ne = JsApiErrors.NewJSRestoreSubscribeFailedError(new Exception("failed error"), "the.subject");
|
||||
ne.Description.ShouldBe("JetStream unable to subscribe to restore snapshot the.subject: failed error");
|
||||
ReferenceEquals(ne, JsApiErrors.RestoreSubscribeFailed).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact] // T:1384
|
||||
public void ApiError_NewWithUnless_ShouldSucceed()
|
||||
{
|
||||
var notEnabled = JsApiErrors.NotEnabledForAccount.ErrCode;
|
||||
var streamRestore = JsApiErrors.StreamRestore.ErrCode;
|
||||
var peerRemap = JsApiErrors.PeerRemap.ErrCode;
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NewJSStreamRestoreError(
|
||||
new Exception("failed error"),
|
||||
JsApiErrors.Unless(JsApiErrors.NotEnabledForAccount)),
|
||||
notEnabled).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NewJSStreamRestoreError(new Exception("failed error")),
|
||||
streamRestore).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NewJSStreamRestoreError(
|
||||
new Exception("failed error"),
|
||||
JsApiErrors.Unless(new Exception("other error"))),
|
||||
streamRestore).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NewJSPeerRemapError(JsApiErrors.Unless(JsApiErrors.NotEnabledForAccount)),
|
||||
notEnabled).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NewJSPeerRemapError(JsApiErrors.Unless(null)),
|
||||
peerRemap).ShouldBeTrue();
|
||||
|
||||
JsApiErrors.IsNatsErr(
|
||||
JsApiErrors.NewJSPeerRemapError(JsApiErrors.Unless(new Exception("other error"))),
|
||||
peerRemap).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.JetStream;
|
||||
|
||||
public sealed class JetStreamFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void StoreMsg_LoadAndPurge_ShouldRoundTrip()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"fs-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var fs = NewStore(root);
|
||||
|
||||
var (seq1, _) = fs.StoreMsg("foo", [1], [2, 3], 0);
|
||||
var (seq2, _) = fs.StoreMsg("bar", null, [4, 5], 0);
|
||||
|
||||
seq1.ShouldBe(1UL);
|
||||
seq2.ShouldBe(2UL);
|
||||
fs.State().Msgs.ShouldBe(2UL);
|
||||
|
||||
var msg = fs.LoadMsg(1, null);
|
||||
msg.ShouldNotBeNull();
|
||||
msg!.Subject.ShouldBe("foo");
|
||||
|
||||
fs.SubjectForSeq(2).Subject.ShouldBe("bar");
|
||||
fs.SubjectsTotals(string.Empty).Count.ShouldBe(2);
|
||||
|
||||
var (removed, remErr) = fs.RemoveMsg(1);
|
||||
removed.ShouldBeTrue();
|
||||
remErr.ShouldBeNull();
|
||||
fs.State().Msgs.ShouldBe(1UL);
|
||||
|
||||
var (purged, purgeErr) = fs.Purge();
|
||||
purgeErr.ShouldBeNull();
|
||||
purged.ShouldBe(1UL);
|
||||
fs.State().Msgs.ShouldBe(0UL);
|
||||
|
||||
var (snapshot, snapErr) = fs.Snapshot(TimeSpan.FromSeconds(1), includeConsumers: false, checkMsgs: false);
|
||||
snapErr.ShouldBeNull();
|
||||
snapshot.ShouldNotBeNull();
|
||||
snapshot!.Reader.ShouldNotBeNull();
|
||||
|
||||
var (total, reported, utilErr) = fs.Utilization();
|
||||
utilErr.ShouldBeNull();
|
||||
total.ShouldBe(reported);
|
||||
|
||||
fs.Stop();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static JetStreamFileStore NewStore(string root)
|
||||
{
|
||||
return new JetStreamFileStore(
|
||||
new FileStoreConfig { StoreDir = root },
|
||||
new FileStreamInfo
|
||||
{
|
||||
Created = DateTime.UtcNow,
|
||||
Config = new StreamConfig
|
||||
{
|
||||
Name = "S",
|
||||
Storage = StorageType.FileStorage,
|
||||
Subjects = ["foo", "bar"],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user