Improve XML documentation coverage across src modules and sync generated analysis artifacts.
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# dotTrace DTP Parser Design
|
||||||
|
|
||||||
|
**Goal:** Build a repository-local tool that starts from a raw dotTrace `.dtp` snapshot family and emits machine-readable JSON call-tree data suitable for LLM-driven hotspot analysis.
|
||||||
|
|
||||||
|
**Context**
|
||||||
|
|
||||||
|
The target snapshot format is JetBrains dotTrace multi-file storage:
|
||||||
|
|
||||||
|
- `snapshot.dtp` is the index/manifest.
|
||||||
|
- `snapshot.dtp.0000`, `.0001`, and related files hold the storage sections.
|
||||||
|
- `snapshot.dtp.States` holds UI state and is not sufficient for call-tree analysis.
|
||||||
|
|
||||||
|
The internal binary layout is not publicly specified. A direct handwritten decoder would be brittle and expensive to maintain. The machine already has dotTrace installed, and the shipped JetBrains assemblies expose snapshot storage, metadata, and performance call-tree readers. The design therefore uses dotTrace’s local runtime libraries as the authoritative decoder while still starting from the raw `.dtp` files.
|
||||||
|
|
||||||
|
**Architecture**
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
1. A small .NET helper opens the raw snapshot, reads the performance DFS call-tree and node payload sections, resolves function names through the profiler metadata section, and emits JSON.
|
||||||
|
2. A Python CLI is the user-facing entrypoint. It validates input, builds or reuses the helper, runs it, and writes JSON to stdout or a file.
|
||||||
|
|
||||||
|
This keeps the user workflow Python-first while using the only reliable decoder available for the undocumented snapshot format.
|
||||||
|
|
||||||
|
**Output schema**
|
||||||
|
|
||||||
|
The JSON should support both direct consumption and downstream summarization:
|
||||||
|
|
||||||
|
- `snapshot`: source path, thread count, node count, payload type.
|
||||||
|
- `thread_roots`: thread root metadata.
|
||||||
|
- `call_tree`: synthetic root with recursive children.
|
||||||
|
- `hotspots`: flat top lists for inclusive and exclusive time.
|
||||||
|
|
||||||
|
Each node should include:
|
||||||
|
|
||||||
|
- `id`: stable offset-based identifier.
|
||||||
|
- `name`: resolved method or synthetic node name.
|
||||||
|
- `kind`: `root`, `thread`, `method`, or `special`.
|
||||||
|
- `inclusive_time`
|
||||||
|
- `exclusive_time`
|
||||||
|
- `call_count`
|
||||||
|
- `thread_name` when relevant
|
||||||
|
- `children`
|
||||||
|
|
||||||
|
**Resolution strategy**
|
||||||
|
|
||||||
|
Method names are resolved from the snapshot’s metadata section:
|
||||||
|
|
||||||
|
- Use the snapshot’s FUID-to-metadata converter.
|
||||||
|
- Map `FunctionUID` to `FunctionId`.
|
||||||
|
- Resolve `MetadataId`.
|
||||||
|
- Read function and class data with `MetadataSectionHelpers`.
|
||||||
|
|
||||||
|
Synthetic and special frames fall back to explicit labels instead of opaque numeric values where possible.
|
||||||
|
|
||||||
|
**Error handling**
|
||||||
|
|
||||||
|
The tool should fail loudly for the cases that matter:
|
||||||
|
|
||||||
|
- Missing dotTrace assemblies.
|
||||||
|
- Unsupported snapshot layout.
|
||||||
|
- Missing metadata sections.
|
||||||
|
- Helper build or execution failure.
|
||||||
|
|
||||||
|
Errors should name the failing stage so the Python wrapper can surface actionable messages.
|
||||||
|
|
||||||
|
**Testing**
|
||||||
|
|
||||||
|
Use the checked-in sample snapshot at `snapshots/js-ordered-consume.dtp` for an end-to-end test:
|
||||||
|
|
||||||
|
- JSON parses successfully.
|
||||||
|
- The root contains thread children.
|
||||||
|
- Hotspot lists are populated.
|
||||||
|
- At least one non-special method name is resolved.
|
||||||
|
|
||||||
|
This is enough to verify the extraction path without freezing the entire output.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# dotTrace DTP Parser Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a Python-first tool that reads a raw dotTrace `.dtp` snapshot family and emits JSON call-tree and hotspot data for LLM analysis.
|
||||||
|
|
||||||
|
**Architecture:** A small .NET helper uses JetBrains’ local dotTrace assemblies to decode snapshot storage, performance call-tree nodes, payloads, and metadata. A Python wrapper validates input, builds the helper if needed, runs it, and writes the resulting JSON.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3 standard library, .NET 10 console app, local JetBrains dotTrace assemblies, `unittest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add the failing end-to-end test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/tests/test_dtp_parser.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Write a `unittest` test that runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
and asserts:
|
||||||
|
|
||||||
|
- exit code is `0`
|
||||||
|
- stdout is valid JSON
|
||||||
|
- `call_tree.children` is non-empty
|
||||||
|
- `hotspots.inclusive` is non-empty
|
||||||
|
- at least one node name is not marked as special
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python3 -m unittest tools.tests.test_dtp_parser -v`
|
||||||
|
|
||||||
|
Expected: FAIL because `tools/dtp_parse.py` does not exist yet.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/tests/test_dtp_parser.py
|
||||||
|
git commit -m "test: add dtp parser end-to-end expectation"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Implement the .NET snapshot extractor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/DtpSnapshotExtractor/DtpSnapshotExtractor.csproj`
|
||||||
|
- Create: `tools/DtpSnapshotExtractor/Program.cs`
|
||||||
|
|
||||||
|
**Step 1: Write the minimal implementation**
|
||||||
|
|
||||||
|
Implement a console app that:
|
||||||
|
|
||||||
|
- accepts snapshot path and optional output path
|
||||||
|
- opens the snapshot through JetBrains snapshot storage
|
||||||
|
- constructs performance call-tree and payload readers
|
||||||
|
- resolves method names via metadata sections
|
||||||
|
- builds a JSON object with root tree, thread roots, and hotspot lists
|
||||||
|
- writes JSON to stdout or output file
|
||||||
|
|
||||||
|
**Step 2: Run helper directly**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project tools/DtpSnapshotExtractor -- snapshots/js-ordered-consume.dtp
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON is emitted successfully.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/DtpSnapshotExtractor/DtpSnapshotExtractor.csproj tools/DtpSnapshotExtractor/Program.cs
|
||||||
|
git commit -m "feat: add dottrace snapshot extractor helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Implement the Python entrypoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/dtp_parse.py`
|
||||||
|
|
||||||
|
**Step 1: Write the minimal implementation**
|
||||||
|
|
||||||
|
Implement a CLI that:
|
||||||
|
|
||||||
|
- accepts snapshot path
|
||||||
|
- supports `--out` and `--stdout`
|
||||||
|
- checks that dotTrace assemblies exist in the local install
|
||||||
|
- runs `dotnet run --project tools/DtpSnapshotExtractor -- <snapshot>`
|
||||||
|
- forwards JSON output
|
||||||
|
|
||||||
|
**Step 2: Run the wrapper**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON is emitted successfully.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/dtp_parse.py
|
||||||
|
git commit -m "feat: add python dtp parsing entrypoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Make the test pass and tighten output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tools/DtpSnapshotExtractor/Program.cs`
|
||||||
|
- Modify: `tools/dtp_parse.py`
|
||||||
|
- Modify: `tools/tests/test_dtp_parser.py`
|
||||||
|
|
||||||
|
**Step 1: Run the failing test**
|
||||||
|
|
||||||
|
Run: `python3 -m unittest tools.tests.test_dtp_parser -v`
|
||||||
|
|
||||||
|
Expected: FAIL with an output-schema or execution issue.
|
||||||
|
|
||||||
|
**Step 2: Fix the minimal failing behavior**
|
||||||
|
|
||||||
|
Adjust:
|
||||||
|
|
||||||
|
- special-node labeling
|
||||||
|
- JSON schema stability
|
||||||
|
- helper invocation details
|
||||||
|
- fallback behavior for unresolved metadata
|
||||||
|
|
||||||
|
**Step 3: Re-run the test**
|
||||||
|
|
||||||
|
Run: `python3 -m unittest tools.tests.test_dtp_parser -v`
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/DtpSnapshotExtractor/Program.cs tools/dtp_parse.py tools/tests/test_dtp_parser.py
|
||||||
|
git commit -m "test: verify dtp parser output"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: none unless fixes are required
|
||||||
|
|
||||||
|
**Step 1: Run end-to-end extraction**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --out /tmp/js-ordered-consume-calltree.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON file is created.
|
||||||
|
|
||||||
|
**Step 2: Run test suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest tools.tests.test_dtp_parser -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Inspect a hotspot sample**
|
||||||
|
|
||||||
|
Confirm the JSON contains:
|
||||||
|
|
||||||
|
- resolved method names
|
||||||
|
- inclusive and exclusive hotspot lists
|
||||||
|
- nested thread call trees
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/plans/2026-03-14-dtp-parser-design.md docs/plans/2026-03-14-dtp-parser.md
|
||||||
|
git commit -m "docs: add dtp parser design and plan"
|
||||||
|
```
|
||||||
+78
@@ -196,6 +196,83 @@ Open `.dtp` / `.dtt` snapshot files in:
|
|||||||
open /Users/dohertj2/Applications/dotTrace.app --args ./snapshots/nats-sampling.dtp
|
open /Users/dohertj2/Applications/dotTrace.app --args ./snapshots/nats-sampling.dtp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Parsing Raw `.dtp` Snapshots To JSON
|
||||||
|
|
||||||
|
The repository includes a Python-first parser for raw dotTrace sampling and tracing snapshots:
|
||||||
|
|
||||||
|
- Python entrypoint: [tools/dtp_parse.py](/Users/dohertj2/Desktop/natsdotnet/tools/dtp_parse.py)
|
||||||
|
- .NET helper: [tools/DtpSnapshotExtractor/Program.cs](/Users/dohertj2/Desktop/natsdotnet/tools/DtpSnapshotExtractor/Program.cs)
|
||||||
|
|
||||||
|
The parser starts from the raw `.dtp` snapshot family and emits machine-readable JSON for call-tree and hotspot analysis. It uses the locally installed dotTrace assemblies to decode the snapshot format.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- `python3`
|
||||||
|
- `.NET 10 SDK`
|
||||||
|
- dotTrace installed at `/Users/dohertj2/Applications/dotTrace.app`
|
||||||
|
|
||||||
|
If dotTrace is installed elsewhere, set `DOTTRACE_APP_DIR` to the `Contents/DotFiles` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DOTTRACE_APP_DIR="/path/to/dotTrace.app/Contents/DotFiles"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Print JSON to stdout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp --stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write JSON to a file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/dtp_parse.py snapshots/js-ordered-consume.dtp \
|
||||||
|
--out /tmp/js-ordered-consume-calltree.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output shape
|
||||||
|
|
||||||
|
The generated JSON contains:
|
||||||
|
|
||||||
|
- `snapshot` — source path, payload type, thread count, node count
|
||||||
|
- `threadRoots` — top-level thread roots with inclusive time
|
||||||
|
- `callTree` — nested call tree rooted at a synthetic `<root>`
|
||||||
|
- `hotspots` — flat `inclusive` and `exclusive` method lists
|
||||||
|
|
||||||
|
Hotspot entries are method-first. Synthetic frames such as thread roots are excluded from the hotspot lists so the output is easier to feed into an LLM for slowdown analysis.
|
||||||
|
|
||||||
|
### Typical analysis workflow
|
||||||
|
|
||||||
|
1. Capture a snapshot with `dottrace`.
|
||||||
|
2. Convert the raw `.dtp` snapshot to JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/dtp_parse.py snapshots/nats-sampling.dtp \
|
||||||
|
--out /tmp/nats-sampling-calltree.json
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Inspect the top hotspots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
with open('/tmp/nats-sampling-calltree.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print('Top inclusive:', data['hotspots']['inclusive'][0]['name'])
|
||||||
|
print('Top exclusive:', data['hotspots']['exclusive'][0]['name'])
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Feed the JSON into downstream tooling or an LLM to walk the call tree and identify expensive paths.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run the parser test with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest tools.tests.test_dtp_parser -v
|
||||||
|
```
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
@@ -212,3 +289,4 @@ open /Users/dohertj2/Applications/dotTrace.app --args ./snapshots/nats-sampling.
|
|||||||
```bash
|
```bash
|
||||||
mkdir -p snapshots
|
mkdir -p snapshots
|
||||||
```
|
```
|
||||||
|
- The parser currently targets raw `.dtp` snapshots. Timeline `.dtt` snapshots are still intended for the GUI viewer.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
-8104
File diff suppressed because it is too large
Load Diff
+2
-3162
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,11 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public sealed class AccountConfig
|
public sealed class AccountConfig
|
||||||
{
|
{
|
||||||
|
/// <summary>Maximum concurrent client connections allowed for this account (0 = unlimited).</summary>
|
||||||
public int MaxConnections { get; init; } // 0 = unlimited
|
public int MaxConnections { get; init; } // 0 = unlimited
|
||||||
|
/// <summary>Maximum subscriptions per client/account context (0 = unlimited).</summary>
|
||||||
public int MaxSubscriptions { get; init; } // 0 = unlimited
|
public int MaxSubscriptions { get; init; } // 0 = unlimited
|
||||||
|
/// <summary>Default publish/subscribe permissions applied to users in this account.</summary>
|
||||||
public Permissions? DefaultPermissions { get; init; }
|
public Permissions? DefaultPermissions { get; init; }
|
||||||
|
|
||||||
/// <summary>Service and stream exports from this account.</summary>
|
/// <summary>Service and stream exports from this account.</summary>
|
||||||
@@ -19,7 +22,9 @@ public sealed class AccountConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ExportDefinition
|
public sealed class ExportDefinition
|
||||||
{
|
{
|
||||||
|
/// <summary>Service subject exported to other accounts.</summary>
|
||||||
public string? Service { get; init; }
|
public string? Service { get; init; }
|
||||||
|
/// <summary>Stream subject exported to other accounts.</summary>
|
||||||
public string? Stream { get; init; }
|
public string? Stream { get; init; }
|
||||||
|
|
||||||
/// <summary>Optional latency tracking subject (e.g. "latency.svc.echo").</summary>
|
/// <summary>Optional latency tracking subject (e.g. "latency.svc.echo").</summary>
|
||||||
@@ -36,9 +41,14 @@ public sealed class ExportDefinition
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImportDefinition
|
public sealed class ImportDefinition
|
||||||
{
|
{
|
||||||
|
/// <summary>Remote account name for imported service mappings.</summary>
|
||||||
public string? ServiceAccount { get; init; }
|
public string? ServiceAccount { get; init; }
|
||||||
|
/// <summary>Remote service subject imported from <see cref="ServiceAccount"/>.</summary>
|
||||||
public string? ServiceSubject { get; init; }
|
public string? ServiceSubject { get; init; }
|
||||||
|
/// <summary>Remote account name for imported stream mappings.</summary>
|
||||||
public string? StreamAccount { get; init; }
|
public string? StreamAccount { get; init; }
|
||||||
|
/// <summary>Remote stream subject imported from <see cref="StreamAccount"/>.</summary>
|
||||||
public string? StreamSubject { get; init; }
|
public string? StreamSubject { get; init; }
|
||||||
|
/// <summary>Local remapped subject for imported services/streams.</summary>
|
||||||
public string? To { get; init; }
|
public string? To { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public static class AccountImportExport
|
|||||||
/// Returns true if following service imports from <paramref name="from"/>
|
/// Returns true if following service imports from <paramref name="from"/>
|
||||||
/// eventually leads back to <paramref name="to"/>.
|
/// eventually leads back to <paramref name="to"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="from">Starting account whose service-import edges are traversed.</param>
|
||||||
|
/// <param name="to">Target account that would indicate an import cycle if reached.</param>
|
||||||
|
/// <param name="visited">Visited account-name set used to avoid infinite graph recursion.</param>
|
||||||
public static bool DetectCycle(Account from, Account to, HashSet<string>? visited = null)
|
public static bool DetectCycle(Account from, Account to, HashSet<string>? visited = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(from);
|
ArgumentNullException.ThrowIfNull(from);
|
||||||
@@ -48,6 +51,9 @@ public static class AccountImportExport
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates that the import is authorized and does not create a cycle.
|
/// Validates that the import is authorized and does not create a cycle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="importingAccount">Account requesting to import a service.</param>
|
||||||
|
/// <param name="exportingAccount">Account exporting the requested service subject.</param>
|
||||||
|
/// <param name="exportSubject">Exported service subject being imported.</param>
|
||||||
/// <exception cref="UnauthorizedAccessException">Thrown when the importing account is not authorized.</exception>
|
/// <exception cref="UnauthorizedAccessException">Thrown when the importing account is not authorized.</exception>
|
||||||
/// <exception cref="InvalidOperationException">Thrown when the import would create a cycle.</exception>
|
/// <exception cref="InvalidOperationException">Thrown when the import would create a cycle.</exception>
|
||||||
public static void ValidateImport(Account importingAccount, Account exportingAccount, string exportSubject)
|
public static void ValidateImport(Account importingAccount, Account exportingAccount, string exportSubject)
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public interface IExternalAuthClient
|
public interface IExternalAuthClient
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Requests an allow/deny decision from an external authentication provider.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Credential material and identity hints from the client connection.</param>
|
||||||
|
/// <param name="ct">Cancellation token bound to auth timeout and connection lifecycle.</param>
|
||||||
Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct);
|
Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,14 +24,36 @@ public record ExternalAuthDecision(
|
|||||||
|
|
||||||
public sealed class ExternalAuthOptions
|
public sealed class ExternalAuthOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether external auth callouts are enabled.
|
||||||
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the timeout budget for each external auth decision request.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(2);
|
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the client implementation responsible for external auth decisions.
|
||||||
|
/// </summary>
|
||||||
public IExternalAuthClient? Client { get; set; }
|
public IExternalAuthClient? Client { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ProxyAuthOptions
|
public sealed class ProxyAuthOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether trusted-proxy authentication mode is enabled.
|
||||||
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the required username prefix marking identities provided by a trusted proxy.
|
||||||
|
/// </summary>
|
||||||
public string UsernamePrefix { get; set; } = "proxy:";
|
public string UsernamePrefix { get; set; } = "proxy:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the default account to assign when proxy-authenticated users omit one.
|
||||||
|
/// </summary>
|
||||||
public string? Account { get; set; }
|
public string? Account { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,33 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public sealed class AuthResult
|
public sealed class AuthResult
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved client identity that successfully authenticated.
|
||||||
|
/// </summary>
|
||||||
public required string Identity { get; init; }
|
public required string Identity { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the account name assigned to the authenticated identity.
|
||||||
|
/// </summary>
|
||||||
public string? AccountName { get; init; }
|
public string? AccountName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets effective publish/subscribe permissions applied to the connection.
|
||||||
|
/// </summary>
|
||||||
public Permissions? Permissions { get; init; }
|
public Permissions? Permissions { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the credential expiry timestamp after which the connection should be considered invalid.
|
||||||
|
/// </summary>
|
||||||
public DateTimeOffset? Expiry { get; init; }
|
public DateTimeOffset? Expiry { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum number of JetStream streams permitted for this identity.
|
||||||
|
/// </summary>
|
||||||
public int MaxJetStreamStreams { get; init; }
|
public int MaxJetStreamStreams { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the JetStream tier assigned for quota enforcement.
|
||||||
|
/// </summary>
|
||||||
public string? JetStreamTier { get; init; }
|
public string? JetStreamTier { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ public sealed class AuthService
|
|||||||
private readonly string? _noAuthUser;
|
private readonly string? _noAuthUser;
|
||||||
private readonly Dictionary<string, User>? _usersMap;
|
private readonly Dictionary<string, User>? _usersMap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether any authentication mechanism is configured.
|
||||||
|
/// </summary>
|
||||||
public bool IsAuthRequired { get; }
|
public bool IsAuthRequired { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the protocol must issue a nonce challenge.
|
||||||
|
/// </summary>
|
||||||
public bool NonceRequired { get; }
|
public bool NonceRequired { get; }
|
||||||
|
|
||||||
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
|
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
|
||||||
@@ -28,6 +35,10 @@ public sealed class AuthService
|
|||||||
_usersMap = usersMap;
|
_usersMap = usersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an authentication service from server options and configured auth sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Server options containing static users, tokens, NKeys, and auth extensions.</param>
|
||||||
public static AuthService Build(NatsOptions options)
|
public static AuthService Build(NatsOptions options)
|
||||||
{
|
{
|
||||||
var authenticators = new List<IAuthenticator>();
|
var authenticators = new List<IAuthenticator>();
|
||||||
@@ -97,6 +108,10 @@ public sealed class AuthService
|
|||||||
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
|
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to authenticate a client CONNECT context against configured authenticators.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Client auth context extracted from CONNECT and transport metadata.</param>
|
||||||
public AuthResult? Authenticate(ClientAuthContext context)
|
public AuthResult? Authenticate(ClientAuthContext context)
|
||||||
{
|
{
|
||||||
if (!IsAuthRequired)
|
if (!IsAuthRequired)
|
||||||
@@ -145,6 +160,9 @@ public sealed class AuthService
|
|||||||
return new AuthResult { Identity = _noAuthUser };
|
return new AuthResult { Identity = _noAuthUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates cryptographically strong nonce bytes for NKey/JWT signature challenges.
|
||||||
|
/// </summary>
|
||||||
public byte[] GenerateNonce()
|
public byte[] GenerateNonce()
|
||||||
{
|
{
|
||||||
Span<byte> raw = stackalloc byte[11];
|
Span<byte> raw = stackalloc byte[11];
|
||||||
@@ -152,6 +170,13 @@ public sealed class AuthService
|
|||||||
return raw.ToArray();
|
return raw.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates MQTT username/password fields against configured MQTT auth settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuredUsername">Username configured on the server for MQTT auth.</param>
|
||||||
|
/// <param name="configuredPassword">Password configured on the server for MQTT auth.</param>
|
||||||
|
/// <param name="providedUsername">Username supplied by the connecting MQTT client.</param>
|
||||||
|
/// <param name="providedPassword">Password supplied by the connecting MQTT client.</param>
|
||||||
public static bool ValidateMqttCredentials(
|
public static bool ValidateMqttCredentials(
|
||||||
string? configuredUsername,
|
string? configuredUsername,
|
||||||
string? configuredPassword,
|
string? configuredPassword,
|
||||||
@@ -165,6 +190,10 @@ public sealed class AuthService
|
|||||||
&& string.Equals(configuredPassword, providedPassword, StringComparison.Ordinal);
|
&& string.Equals(configuredPassword, providedPassword, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes nonce bytes into URL-safe base64 format used by NATS auth challenges.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nonce">Raw nonce bytes generated for the challenge.</param>
|
||||||
public string EncodeNonce(byte[] nonce)
|
public string EncodeNonce(byte[] nonce)
|
||||||
{
|
{
|
||||||
return Convert.ToBase64String(nonce)
|
return Convert.ToBase64String(nonce)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public sealed class ClientPermissions : IDisposable
|
|||||||
_responseTracker = responseTracker;
|
_responseTracker = responseTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a runtime client-permissions evaluator from account/user permission config.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="permissions">Permission configuration from auth claims or static config.</param>
|
||||||
public static ClientPermissions? Build(Permissions? permissions)
|
public static ClientPermissions? Build(Permissions? permissions)
|
||||||
{
|
{
|
||||||
if (permissions == null)
|
if (permissions == null)
|
||||||
@@ -33,8 +37,11 @@ public sealed class ClientPermissions : IDisposable
|
|||||||
return new ClientPermissions(pub, sub, responseTracker);
|
return new ClientPermissions(pub, sub, responseTracker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Optional tracker used to authorize dynamic response subjects.</summary>
|
||||||
public ResponseTracker? ResponseTracker => _responseTracker;
|
public ResponseTracker? ResponseTracker => _responseTracker;
|
||||||
|
|
||||||
|
/// <summary>Determines whether publishing to the given subject is permitted.</summary>
|
||||||
|
/// <param name="subject">Publish subject being authorized for the client.</param>
|
||||||
public bool IsPublishAllowed(string subject)
|
public bool IsPublishAllowed(string subject)
|
||||||
{
|
{
|
||||||
if (_publish == null)
|
if (_publish == null)
|
||||||
@@ -56,6 +63,9 @@ public sealed class ClientPermissions : IDisposable
|
|||||||
return allowed;
|
return allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Determines whether subscribing to the given subject/queue is permitted.</summary>
|
||||||
|
/// <param name="subject">Subscription subject being authorized.</param>
|
||||||
|
/// <param name="queue">Optional queue group name for queue-subscription checks.</param>
|
||||||
public bool IsSubscribeAllowed(string subject, string? queue = null)
|
public bool IsSubscribeAllowed(string subject, string? queue = null)
|
||||||
{
|
{
|
||||||
if (_subscribe == null)
|
if (_subscribe == null)
|
||||||
@@ -67,6 +77,8 @@ public sealed class ClientPermissions : IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Determines whether delivering a message on the subject is permitted.</summary>
|
||||||
|
/// <param name="subject">Delivery subject evaluated against deny rules.</param>
|
||||||
public bool IsDeliveryAllowed(string subject)
|
public bool IsDeliveryAllowed(string subject)
|
||||||
{
|
{
|
||||||
if (_subscribe == null)
|
if (_subscribe == null)
|
||||||
@@ -74,6 +86,7 @@ public sealed class ClientPermissions : IDisposable
|
|||||||
return _subscribe.IsDeliveryAllowed(subject);
|
return _subscribe.IsDeliveryAllowed(subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes permission resources used by this evaluator.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_publish?.Dispose();
|
_publish?.Dispose();
|
||||||
@@ -92,6 +105,10 @@ public sealed class PermissionSet : IDisposable
|
|||||||
_deny = deny;
|
_deny = deny;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds allow/deny sublists from a subject-permission definition.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="permission">Allow/deny subject rules.</param>
|
||||||
public static PermissionSet? Build(SubjectPermission? permission)
|
public static PermissionSet? Build(SubjectPermission? permission)
|
||||||
{
|
{
|
||||||
if (permission == null)
|
if (permission == null)
|
||||||
@@ -123,6 +140,8 @@ public sealed class PermissionSet : IDisposable
|
|||||||
return new PermissionSet(allow, deny);
|
return new PermissionSet(allow, deny);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks whether a subject passes allow/deny evaluation.</summary>
|
||||||
|
/// <param name="subject">Subject candidate to evaluate against allow and deny lists.</param>
|
||||||
public bool IsAllowed(string subject)
|
public bool IsAllowed(string subject)
|
||||||
{
|
{
|
||||||
bool allowed = true;
|
bool allowed = true;
|
||||||
@@ -142,6 +161,8 @@ public sealed class PermissionSet : IDisposable
|
|||||||
return allowed;
|
return allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks whether a subject is explicitly denied.</summary>
|
||||||
|
/// <param name="subject">Subject candidate evaluated against deny entries.</param>
|
||||||
public bool IsDenied(string subject)
|
public bool IsDenied(string subject)
|
||||||
{
|
{
|
||||||
if (_deny == null) return false;
|
if (_deny == null) return false;
|
||||||
@@ -149,6 +170,8 @@ public sealed class PermissionSet : IDisposable
|
|||||||
return result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
|
return result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks delivery permission using deny-list semantics.</summary>
|
||||||
|
/// <param name="subject">Subject being delivered to a subscriber.</param>
|
||||||
public bool IsDeliveryAllowed(string subject)
|
public bool IsDeliveryAllowed(string subject)
|
||||||
{
|
{
|
||||||
if (_deny == null)
|
if (_deny == null)
|
||||||
@@ -157,6 +180,7 @@ public sealed class PermissionSet : IDisposable
|
|||||||
return result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
|
return result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes internal allow/deny sublists.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_allow?.Dispose();
|
_allow?.Dispose();
|
||||||
|
|||||||
@@ -6,13 +6,28 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public interface IAuthenticator
|
public interface IAuthenticator
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to authenticate a client connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Authentication context containing credentials and transport metadata.</param>
|
||||||
AuthResult? Authenticate(ClientAuthContext context);
|
AuthResult? Authenticate(ClientAuthContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ClientAuthContext
|
public sealed class ClientAuthContext
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets CONNECT options and credential fields supplied by the client.
|
||||||
|
/// </summary>
|
||||||
public required ClientOptions Opts { get; init; }
|
public required ClientOptions Opts { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets server-issued nonce bytes used for signature-based auth flows.
|
||||||
|
/// </summary>
|
||||||
public required byte[] Nonce { get; init; }
|
public required byte[] Nonce { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the client TLS certificate presented during handshake, when available.
|
||||||
|
/// </summary>
|
||||||
public X509Certificate2? ClientCertificate { get; init; }
|
public X509Certificate2? ClientCertificate { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ public interface IAccountResolver
|
|||||||
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
|
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
|
||||||
/// the NKey is not known to this resolver.
|
/// the NKey is not known to this resolver.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="accountNkey">Account public NKey used as resolver lookup key.</param>
|
||||||
Task<string?> FetchAsync(string accountNkey);
|
Task<string?> FetchAsync(string accountNkey);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stores (or replaces) the JWT for the given account NKey. Callers that
|
/// Stores (or replaces) the JWT for the given account NKey. Callers that
|
||||||
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
|
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="accountNkey">Account public NKey used as resolver storage key.</param>
|
||||||
|
/// <param name="jwt">Account JWT content associated with the key.</param>
|
||||||
Task StoreAsync(string accountNkey, string jwt);
|
Task StoreAsync(string accountNkey, string jwt);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class NatsJwt
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the string appears to be a JWT (starts with "eyJ").
|
/// Returns true if the string appears to be a JWT (starts with "eyJ").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="token">Token string to inspect.</param>
|
||||||
public static bool IsJwt(string token)
|
public static bool IsJwt(string token)
|
||||||
{
|
{
|
||||||
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
|
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
|
||||||
@@ -28,6 +29,7 @@ public static class NatsJwt
|
|||||||
/// Decodes a JWT token into its constituent parts without verifying the signature.
|
/// Decodes a JWT token into its constituent parts without verifying the signature.
|
||||||
/// Returns null if the token is structurally invalid.
|
/// Returns null if the token is structurally invalid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="token">JWT string in header.payload.signature format.</param>
|
||||||
public static JwtToken? Decode(string token)
|
public static JwtToken? Decode(string token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -68,6 +70,7 @@ public static class NatsJwt
|
|||||||
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
|
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
|
||||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="token">JWT string to decode.</param>
|
||||||
public static UserClaims? DecodeUserClaims(string token)
|
public static UserClaims? DecodeUserClaims(string token)
|
||||||
{
|
{
|
||||||
var jwt = Decode(token);
|
var jwt = Decode(token);
|
||||||
@@ -88,6 +91,7 @@ public static class NatsJwt
|
|||||||
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
|
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
|
||||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="token">JWT string to decode.</param>
|
||||||
public static AccountClaims? DecodeAccountClaims(string token)
|
public static AccountClaims? DecodeAccountClaims(string token)
|
||||||
{
|
{
|
||||||
var jwt = Decode(token);
|
var jwt = Decode(token);
|
||||||
@@ -107,6 +111,8 @@ public static class NatsJwt
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
|
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="token">JWT string to verify.</param>
|
||||||
|
/// <param name="publicNkey">Expected signer public NKey.</param>
|
||||||
public static bool Verify(string token, string publicNkey)
|
public static bool Verify(string token, string publicNkey)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -129,6 +135,9 @@ public static class NatsJwt
|
|||||||
/// Verifies a nonce signature against the given NKey public key.
|
/// Verifies a nonce signature against the given NKey public key.
|
||||||
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
|
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="nonce">Raw nonce bytes originally issued by the server.</param>
|
||||||
|
/// <param name="signature">Signature string provided by the client.</param>
|
||||||
|
/// <param name="publicNkey">Client public NKey used for verification.</param>
|
||||||
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -150,6 +159,7 @@ public static class NatsJwt
|
|||||||
/// Decodes a base64url-encoded byte array.
|
/// Decodes a base64url-encoded byte array.
|
||||||
/// Replaces URL-safe characters and adds padding as needed.
|
/// Replaces URL-safe characters and adds padding as needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="input">Base64url-encoded string.</param>
|
||||||
internal static byte[] Base64UrlDecode(string input)
|
internal static byte[] Base64UrlDecode(string input)
|
||||||
{
|
{
|
||||||
var s = input.Replace('-', '+').Replace('_', '/');
|
var s = input.Replace('-', '+').Replace('_', '/');
|
||||||
@@ -214,8 +224,10 @@ public sealed class JwtToken
|
|||||||
public sealed class JwtHeader
|
public sealed class JwtHeader
|
||||||
{
|
{
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("alg")]
|
[System.Text.Json.Serialization.JsonPropertyName("alg")]
|
||||||
|
/// <summary>JWT signing algorithm identifier (typically <c>ed25519-nkey</c> for NATS).</summary>
|
||||||
public string? Algorithm { get; set; }
|
public string? Algorithm { get; set; }
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("typ")]
|
[System.Text.Json.Serialization.JsonPropertyName("typ")]
|
||||||
|
/// <summary>JWT type marker (typically <c>JWT</c>).</summary>
|
||||||
public string? Type { get; set; }
|
public string? Type { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,38 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public sealed class NKeyUser
|
public sealed class NKeyUser
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the public NKey used for challenge-signature authentication.
|
||||||
|
/// </summary>
|
||||||
public required string Nkey { get; init; }
|
public required string Nkey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets publish/subscribe permission rules assigned to this NKey identity.
|
||||||
|
/// </summary>
|
||||||
public Permissions? Permissions { get; init; }
|
public Permissions? Permissions { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the account this NKey user is bound to.
|
||||||
|
/// </summary>
|
||||||
public string? Account { get; init; }
|
public string? Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an optional signing key used for delegated user JWT issuance.
|
||||||
|
/// </summary>
|
||||||
public string? SigningKey { get; init; }
|
public string? SigningKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the issuance timestamp associated with this identity claim.
|
||||||
|
/// </summary>
|
||||||
public DateTimeOffset? Issued { get; init; }
|
public DateTimeOffset? Issued { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets optional connection-type restrictions for this identity.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this identity must be presented through proxy auth.
|
||||||
|
/// </summary>
|
||||||
public bool ProxyRequired { get; init; }
|
public bool ProxyRequired { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public sealed class PermissionLruCache
|
|||||||
private long _generation;
|
private long _generation;
|
||||||
private long _cacheGeneration;
|
private long _cacheGeneration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a fixed-capacity permission LRU cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="capacity">Maximum number of cached permission decisions.</param>
|
||||||
public PermissionLruCache(int capacity = 128)
|
public PermissionLruCache(int capacity = 128)
|
||||||
{
|
{
|
||||||
_capacity = capacity;
|
_capacity = capacity;
|
||||||
@@ -51,6 +55,8 @@ public sealed class PermissionLruCache
|
|||||||
// ── PUB API (backward-compatible) ────────────────────────────────────────
|
// ── PUB API (backward-compatible) ────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Looks up a PUB permission for <paramref name="key"/>.</summary>
|
/// <summary>Looks up a PUB permission for <paramref name="key"/>.</summary>
|
||||||
|
/// <param name="key">Publish subject cache key.</param>
|
||||||
|
/// <param name="value">Cached allow/deny decision when present.</param>
|
||||||
public bool TryGet(string key, out bool value)
|
public bool TryGet(string key, out bool value)
|
||||||
{
|
{
|
||||||
var internalKey = "P:" + key;
|
var internalKey = "P:" + key;
|
||||||
@@ -71,6 +77,8 @@ public sealed class PermissionLruCache
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stores a PUB permission for <paramref name="key"/>.</summary>
|
/// <summary>Stores a PUB permission for <paramref name="key"/>.</summary>
|
||||||
|
/// <param name="key">Publish subject cache key.</param>
|
||||||
|
/// <param name="value">Allow/deny decision to cache.</param>
|
||||||
public void Set(string key, bool value)
|
public void Set(string key, bool value)
|
||||||
{
|
{
|
||||||
var internalKey = "P:" + key;
|
var internalKey = "P:" + key;
|
||||||
@@ -84,6 +92,8 @@ public sealed class PermissionLruCache
|
|||||||
// ── SUB API ───────────────────────────────────────────────────────────────
|
// ── SUB API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Looks up a SUB permission for <paramref name="subject"/>.</summary>
|
/// <summary>Looks up a SUB permission for <paramref name="subject"/>.</summary>
|
||||||
|
/// <param name="subject">Subscribe subject cache key.</param>
|
||||||
|
/// <param name="value">Cached allow/deny decision when present.</param>
|
||||||
public bool TryGetSub(string subject, out bool value)
|
public bool TryGetSub(string subject, out bool value)
|
||||||
{
|
{
|
||||||
var internalKey = "S:" + subject;
|
var internalKey = "S:" + subject;
|
||||||
@@ -104,6 +114,8 @@ public sealed class PermissionLruCache
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stores a SUB permission for <paramref name="subject"/>.</summary>
|
/// <summary>Stores a SUB permission for <paramref name="subject"/>.</summary>
|
||||||
|
/// <param name="subject">Subscribe subject cache key.</param>
|
||||||
|
/// <param name="allowed">Allow/deny decision to cache.</param>
|
||||||
public void SetSub(string subject, bool allowed)
|
public void SetSub(string subject, bool allowed)
|
||||||
{
|
{
|
||||||
var internalKey = "S:" + subject;
|
var internalKey = "S:" + subject;
|
||||||
@@ -116,6 +128,7 @@ public sealed class PermissionLruCache
|
|||||||
|
|
||||||
// ── Shared ────────────────────────────────────────────────────────────────
|
// ── Shared ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Current number of cached entries.</summary>
|
||||||
public int Count
|
public int Count
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -2,19 +2,44 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public sealed class Permissions
|
public sealed class Permissions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets publish-side allow/deny subject rules.
|
||||||
|
/// </summary>
|
||||||
public SubjectPermission? Publish { get; init; }
|
public SubjectPermission? Publish { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets subscribe-side allow/deny subject rules.
|
||||||
|
/// </summary>
|
||||||
public SubjectPermission? Subscribe { get; init; }
|
public SubjectPermission? Subscribe { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets dynamic reply-publish permissions granted to request responders.
|
||||||
|
/// </summary>
|
||||||
public ResponsePermission? Response { get; init; }
|
public ResponsePermission? Response { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SubjectPermission
|
public sealed class SubjectPermission
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets subject patterns explicitly permitted for the operation.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<string>? Allow { get; init; }
|
public IReadOnlyList<string>? Allow { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets subject patterns explicitly denied for the operation.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<string>? Deny { get; init; }
|
public IReadOnlyList<string>? Deny { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ResponsePermission
|
public sealed class ResponsePermission
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum number of response messages allowed on auto-generated reply subjects.
|
||||||
|
/// </summary>
|
||||||
public int MaxMsgs { get; init; }
|
public int MaxMsgs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the expiration window for temporary response permissions.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan Expires { get; init; }
|
public TimeSpan Expires { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,29 @@ public sealed class ResponseTracker
|
|||||||
private readonly Dictionary<string, (DateTime RegisteredAt, int Count)> _replies = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, (DateTime RegisteredAt, int Count)> _replies = new(StringComparer.Ordinal);
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a tracker for temporary response-subject permissions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="maxMsgs">Maximum allowed publishes per reply subject (0 for unlimited).</param>
|
||||||
|
/// <param name="expires">TTL for each registered reply subject (<see cref="TimeSpan.Zero"/> for no TTL).</param>
|
||||||
public ResponseTracker(int maxMsgs, TimeSpan expires)
|
public ResponseTracker(int maxMsgs, TimeSpan expires)
|
||||||
{
|
{
|
||||||
_maxMsgs = maxMsgs;
|
_maxMsgs = maxMsgs;
|
||||||
_expires = expires;
|
_expires = expires;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of currently tracked reply subjects.
|
||||||
|
/// </summary>
|
||||||
public int Count
|
public int Count
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _replies.Count; }
|
get { lock (_lock) return _replies.Count; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a reply subject for temporary publish authorization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="replySubject">Reply subject allowed for responder publishes.</param>
|
||||||
public void RegisterReply(string replySubject)
|
public void RegisterReply(string replySubject)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -30,6 +42,10 @@ public sealed class ResponseTracker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a publish to the reply subject is currently allowed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Reply subject being authorized.</param>
|
||||||
public bool IsReplyAllowed(string subject)
|
public bool IsReplyAllowed(string subject)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -55,6 +71,9 @@ public sealed class ResponseTracker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes expired or exhausted reply permissions from the tracker.
|
||||||
|
/// </summary>
|
||||||
public void Prune()
|
public void Prune()
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ public sealed class ServiceLatencyTracker
|
|||||||
private readonly int _maxSamples;
|
private readonly int _maxSamples;
|
||||||
private long _totalRequests;
|
private long _totalRequests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a latency tracker with a bounded in-memory sample window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="maxSamples">Maximum number of latency samples retained for percentile calculations.</param>
|
||||||
public ServiceLatencyTracker(int maxSamples = 10000)
|
public ServiceLatencyTracker(int maxSamples = 10000)
|
||||||
{
|
{
|
||||||
_maxSamples = maxSamples;
|
_maxSamples = maxSamples;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Records a latency sample in milliseconds.</summary>
|
/// <summary>Records a latency sample in milliseconds.</summary>
|
||||||
|
/// <param name="latencyMs">Observed end-to-end service latency in milliseconds.</param>
|
||||||
public void RecordLatency(double latencyMs)
|
public void RecordLatency(double latencyMs)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -28,11 +33,15 @@ public sealed class ServiceLatencyTracker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the 50th percentile (median) latency in milliseconds.</summary>
|
||||||
public double GetP50() => GetPercentile(0.50);
|
public double GetP50() => GetPercentile(0.50);
|
||||||
|
/// <summary>Returns the 90th percentile latency in milliseconds.</summary>
|
||||||
public double GetP90() => GetPercentile(0.90);
|
public double GetP90() => GetPercentile(0.90);
|
||||||
|
/// <summary>Returns the 99th percentile latency in milliseconds.</summary>
|
||||||
public double GetP99() => GetPercentile(0.99);
|
public double GetP99() => GetPercentile(0.99);
|
||||||
|
|
||||||
/// <summary>Returns the value at the given percentile (0.0–1.0) over recorded samples.</summary>
|
/// <summary>Returns the value at the given percentile (0.0–1.0) over recorded samples.</summary>
|
||||||
|
/// <param name="percentile">Percentile fraction between 0.0 and 1.0.</param>
|
||||||
public double GetPercentile(double percentile)
|
public double GetPercentile(double percentile)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -61,16 +70,19 @@ public sealed class ServiceLatencyTracker
|
|||||||
return sum / samples.Count;
|
return sum / samples.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Total number of latency observations recorded.</summary>
|
||||||
public long TotalRequests
|
public long TotalRequests
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _totalRequests; }
|
get { lock (_lock) return _totalRequests; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Arithmetic mean latency across currently retained samples.</summary>
|
||||||
public double AverageLatencyMs
|
public double AverageLatencyMs
|
||||||
{
|
{
|
||||||
get { lock (_lock) return ComputeAverage(_samples); }
|
get { lock (_lock) return ComputeAverage(_samples); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimum latency among currently retained samples.</summary>
|
||||||
public double MinLatencyMs
|
public double MinLatencyMs
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -80,6 +92,7 @@ public sealed class ServiceLatencyTracker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Maximum latency among currently retained samples.</summary>
|
||||||
public double MaxLatencyMs
|
public double MaxLatencyMs
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -89,6 +102,7 @@ public sealed class ServiceLatencyTracker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of samples currently retained in memory.</summary>
|
||||||
public int SampleCount
|
public int SampleCount
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _samples.Count; }
|
get { lock (_lock) return _samples.Count; }
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
|||||||
private readonly Dictionary<string, User> _usersByDn;
|
private readonly Dictionary<string, User> _usersByDn;
|
||||||
private readonly Dictionary<string, User> _usersByCn;
|
private readonly Dictionary<string, User> _usersByCn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a TLS-map authenticator using configured users keyed by DN/CN-style identities.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="users">Configured users used for DN/CN lookup matches.</param>
|
||||||
public TlsMapAuthenticator(IReadOnlyList<User> users)
|
public TlsMapAuthenticator(IReadOnlyList<User> users)
|
||||||
{
|
{
|
||||||
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -22,6 +26,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a client by matching certificate subject/SAN data to configured users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Authentication context containing the client TLS certificate.</param>
|
||||||
public AuthResult? Authenticate(ClientAuthContext context)
|
public AuthResult? Authenticate(ClientAuthContext context)
|
||||||
{
|
{
|
||||||
var cert = context.ClientCertificate;
|
var cert = context.ClientCertificate;
|
||||||
@@ -65,6 +73,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts domain-component RDN elements from a distinguished name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dn">Distinguished name to inspect for <c>DC=</c> elements.</param>
|
||||||
internal static string GetTlsAuthDcs(X500DistinguishedName dn)
|
internal static string GetTlsAuthDcs(X500DistinguishedName dn)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dn.Name))
|
if (string.IsNullOrWhiteSpace(dn.Name))
|
||||||
@@ -82,6 +94,10 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
|||||||
return string.Join(",", dcs);
|
return string.Join(",", dcs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a DNS alternative-name value into normalized lowercase labels.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dnsAltName">DNS SAN value from a certificate.</param>
|
||||||
internal static string[] DnsAltNameLabels(string dnsAltName)
|
internal static string[] DnsAltNameLabels(string dnsAltName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dnsAltName))
|
if (string.IsNullOrWhiteSpace(dnsAltName))
|
||||||
@@ -90,6 +106,11 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
|||||||
return dnsAltName.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries);
|
return dnsAltName.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether SAN DNS labels match any URL host in the provided list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dnsAltNameLabels">Normalized SAN label sequence (supports wildcard first label).</param>
|
||||||
|
/// <param name="urls">Candidate URLs whose hosts are compared against SAN labels.</param>
|
||||||
internal static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
|
internal static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
|
||||||
{
|
{
|
||||||
foreach (var url in urls)
|
foreach (var url in urls)
|
||||||
|
|||||||
@@ -2,11 +2,38 @@ namespace NATS.Server.Auth;
|
|||||||
|
|
||||||
public sealed class User
|
public sealed class User
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the username used for CONNECT credential authentication.
|
||||||
|
/// </summary>
|
||||||
public required string Username { get; init; }
|
public required string Username { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the password associated with <see cref="Username"/>.
|
||||||
|
/// </summary>
|
||||||
public required string Password { get; init; }
|
public required string Password { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets publish/subscribe permission rules assigned to this user.
|
||||||
|
/// </summary>
|
||||||
public Permissions? Permissions { get; init; }
|
public Permissions? Permissions { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the account this user is bound to for subject and subscription isolation.
|
||||||
|
/// </summary>
|
||||||
public string? Account { get; init; }
|
public string? Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an optional cutoff timestamp after which new connections are rejected.
|
||||||
|
/// </summary>
|
||||||
public DateTimeOffset? ConnectionDeadline { get; init; }
|
public DateTimeOffset? ConnectionDeadline { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets optional connection-type restrictions (client, route, gateway, leaf, and so on).
|
||||||
|
/// </summary>
|
||||||
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this identity must authenticate through trusted proxy headers.
|
||||||
|
/// </summary>
|
||||||
public bool ProxyRequired { get; init; }
|
public bool ProxyRequired { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,16 +25,28 @@ public sealed class ClientFlagHolder
|
|||||||
{
|
{
|
||||||
private int _flags;
|
private int _flags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomically sets the specified client state flag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="flag">Flag to set.</param>
|
||||||
public void SetFlag(ClientFlags flag)
|
public void SetFlag(ClientFlags flag)
|
||||||
{
|
{
|
||||||
Interlocked.Or(ref _flags, (int)flag);
|
Interlocked.Or(ref _flags, (int)flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomically clears the specified client state flag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="flag">Flag to clear.</param>
|
||||||
public void ClearFlag(ClientFlags flag)
|
public void ClearFlag(ClientFlags flag)
|
||||||
{
|
{
|
||||||
Interlocked.And(ref _flags, ~(int)flag);
|
Interlocked.And(ref _flags, ~(int)flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the specified client state flag is currently set.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="flag">Flag to test.</param>
|
||||||
public bool HasFlag(ClientFlags flag)
|
public bool HasFlag(ClientFlags flag)
|
||||||
{
|
{
|
||||||
return (Volatile.Read(ref _flags) & (int)flag) != 0;
|
return (Volatile.Read(ref _flags) & (int)flag) != 0;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public sealed class ClientTraceInfo
|
|||||||
/// Records a message delivery trace if tracing is enabled.
|
/// Records a message delivery trace if tracing is enabled.
|
||||||
/// Go reference: server/client.go — traceMsg / TraceMsgDelivery.
|
/// Go reference: server/client.go — traceMsg / TraceMsgDelivery.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="subject">Published subject that triggered this delivery path.</param>
|
||||||
|
/// <param name="destination">Destination descriptor such as a client, queue group, or route hop.</param>
|
||||||
|
/// <param name="payloadSize">Payload size in bytes used for throughput and fan-out diagnostics.</param>
|
||||||
public void TraceMsgDelivery(string subject, string destination, int payloadSize)
|
public void TraceMsgDelivery(string subject, string destination, int payloadSize)
|
||||||
{
|
{
|
||||||
if (!TraceEnabled) return;
|
if (!TraceEnabled) return;
|
||||||
@@ -50,6 +53,8 @@ public sealed class ClientTraceInfo
|
|||||||
/// subscriptions on the same client.
|
/// subscriptions on the same client.
|
||||||
/// Go reference: server/client.go — c.echo check in deliverMsg.
|
/// Go reference: server/client.go — c.echo check in deliverMsg.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="publisherClientId">Client identifier that originated the publish.</param>
|
||||||
|
/// <param name="subscriberClientId">Client identifier for the subscription currently being evaluated.</param>
|
||||||
public bool ShouldEcho(string publisherClientId, string subscriberClientId)
|
public bool ShouldEcho(string publisherClientId, string subscriberClientId)
|
||||||
{
|
{
|
||||||
if (EchoEnabled) return true;
|
if (EchoEnabled) return true;
|
||||||
@@ -76,8 +81,23 @@ public sealed class ClientTraceInfo
|
|||||||
|
|
||||||
public sealed record TraceRecord
|
public sealed record TraceRecord
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the routed subject for the traced delivery event.
|
||||||
|
/// </summary>
|
||||||
public string Subject { get; init; } = string.Empty;
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the resolved destination where the server sent the message.
|
||||||
|
/// </summary>
|
||||||
public string Destination { get; init; } = string.Empty;
|
public string Destination { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the payload size in bytes for the traced message.
|
||||||
|
/// </summary>
|
||||||
public int PayloadSize { get; init; }
|
public int PayloadSize { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the UTC timestamp captured when the trace event was recorded.
|
||||||
|
/// </summary>
|
||||||
public DateTime TimestampUtc { get; init; }
|
public DateTime TimestampUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
namespace NATS.Server.Configuration;
|
namespace NATS.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cluster listener and route fan-out settings used for server-to-server mesh links.
|
||||||
|
/// </summary>
|
||||||
public sealed class ClusterOptions
|
public sealed class ClusterOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the local cluster name advertised during route handshakes.
|
||||||
|
/// </summary>
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the network interface used to accept inbound route connections.
|
||||||
|
/// </summary>
|
||||||
public string Host { get; set; } = "0.0.0.0";
|
public string Host { get; set; } = "0.0.0.0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the TCP port for the cluster route listener.
|
||||||
|
/// </summary>
|
||||||
public int Port { get; set; } = 6222;
|
public int Port { get; set; } = 6222;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of parallel route connections maintained per remote server.
|
||||||
|
/// </summary>
|
||||||
public int PoolSize { get; set; } = 3;
|
public int PoolSize { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the configured outbound route URLs used to join peer servers.
|
||||||
|
/// </summary>
|
||||||
public List<string> Routes { get; set; } = [];
|
public List<string> Routes { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets account names that should use dedicated route handling.
|
||||||
|
/// </summary>
|
||||||
public List<string> Accounts { get; set; } = [];
|
public List<string> Accounts { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets compression behavior for inter-server route traffic.
|
||||||
|
/// </summary>
|
||||||
public RouteCompression Compression { get; set; } = RouteCompression.None;
|
public RouteCompression Compression { get; set; } = RouteCompression.None;
|
||||||
|
|
||||||
// Go: opts.go — cluster write_deadline
|
// Go: opts.go — cluster write_deadline
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the write deadline enforced for route protocol socket operations.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan WriteDeadline { get; set; }
|
public TimeSpan WriteDeadline { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class ConfigProcessor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses a configuration file and returns the populated options.
|
/// Parses a configuration file and returns the populated options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="filePath">Absolute or relative path to the NATS configuration file to load.</param>
|
||||||
public static NatsOptions ProcessConfigFile(string filePath)
|
public static NatsOptions ProcessConfigFile(string filePath)
|
||||||
{
|
{
|
||||||
var config = NatsConfParser.ParseFile(filePath);
|
var config = NatsConfParser.ParseFile(filePath);
|
||||||
@@ -30,6 +31,7 @@ public static class ConfigProcessor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses configuration text (not from a file) and returns the populated options.
|
/// Parses configuration text (not from a file) and returns the populated options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="configText">Raw configuration text in NATS server config format.</param>
|
||||||
public static NatsOptions ProcessConfig(string configText)
|
public static NatsOptions ProcessConfig(string configText)
|
||||||
{
|
{
|
||||||
var config = NatsConfParser.Parse(configText);
|
var config = NatsConfParser.Parse(configText);
|
||||||
@@ -42,6 +44,8 @@ public static class ConfigProcessor
|
|||||||
/// Applies a parsed configuration dictionary to existing options.
|
/// Applies a parsed configuration dictionary to existing options.
|
||||||
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
|
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="config">Parsed config tree keyed by top-level field names.</param>
|
||||||
|
/// <param name="opts">Options instance that receives normalized values from the parsed config.</param>
|
||||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||||
{
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
@@ -423,6 +427,7 @@ public static class ConfigProcessor
|
|||||||
/// <item>A number (long/double) treated as seconds</item>
|
/// <item>A number (long/double) treated as seconds</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="value">Raw duration token from configuration (string or numeric seconds).</param>
|
||||||
internal static TimeSpan ParseDuration(object? value)
|
internal static TimeSpan ParseDuration(object? value)
|
||||||
{
|
{
|
||||||
return value switch
|
return value switch
|
||||||
@@ -1877,7 +1882,14 @@ public static class ConfigProcessor
|
|||||||
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
|
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
|
||||||
: Exception(message)
|
: Exception(message)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of blocking configuration errors that prevented startup.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<string> Errors => errors;
|
public IReadOnlyList<string> Errors => errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets non-fatal configuration warnings collected during processing.
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<string> Warnings => warnings ?? [];
|
public IReadOnlyList<string> Warnings => warnings ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1887,6 +1899,9 @@ public sealed class ConfigProcessorException(string message, List<string> errors
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConfigWarningException(string message, string? source = null) : Exception(message)
|
public class ConfigWarningException(string message, string? source = null) : Exception(message)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the location within the source config where this warning originated, when available.
|
||||||
|
/// </summary>
|
||||||
public string? SourceLocation { get; } = source;
|
public string? SourceLocation { get; } = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1897,5 +1912,8 @@ public class ConfigWarningException(string message, string? source = null) : Exc
|
|||||||
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
|
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
|
||||||
: ConfigWarningException($"unknown field {field}", source)
|
: ConfigWarningException($"unknown field {field}", source)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unknown top-level or nested field name encountered in the configuration file.
|
||||||
|
/// </summary>
|
||||||
public string Field { get; } = field;
|
public string Field { get; } = field;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -810,6 +810,11 @@ public sealed class ConfigReloadResult
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a config reload result payload.
|
/// Initializes a config reload result payload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="Unchanged">Whether reload was skipped because the config digest was unchanged.</param>
|
||||||
|
/// <param name="NewOptions">Newly parsed options candidate for applying a reload.</param>
|
||||||
|
/// <param name="NewDigest">Digest string of the candidate config content.</param>
|
||||||
|
/// <param name="Changes">Detected option differences for this reload attempt.</param>
|
||||||
|
/// <param name="Errors">Validation errors that block applying the reload.</param>
|
||||||
public ConfigReloadResult(
|
public ConfigReloadResult(
|
||||||
bool Unchanged,
|
bool Unchanged,
|
||||||
NatsOptions? NewOptions = null,
|
NatsOptions? NewOptions = null,
|
||||||
|
|||||||
@@ -46,9 +46,28 @@ public sealed class ConfigChange(
|
|||||||
bool isTlsChange = false,
|
bool isTlsChange = false,
|
||||||
bool isNonReloadable = false) : IConfigChange
|
bool isNonReloadable = false) : IConfigChange
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the changed option name.
|
||||||
|
/// </summary>
|
||||||
public string Name => name;
|
public string Name => name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this change affects logging configuration.
|
||||||
|
/// </summary>
|
||||||
public bool IsLoggingChange => isLoggingChange;
|
public bool IsLoggingChange => isLoggingChange;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this change affects authentication configuration.
|
||||||
|
/// </summary>
|
||||||
public bool IsAuthChange => isAuthChange;
|
public bool IsAuthChange => isAuthChange;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this change affects TLS configuration.
|
||||||
|
/// </summary>
|
||||||
public bool IsTlsChange => isTlsChange;
|
public bool IsTlsChange => isTlsChange;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this change cannot be applied without restart.
|
||||||
|
/// </summary>
|
||||||
public bool IsNonReloadable => isNonReloadable;
|
public bool IsNonReloadable => isNonReloadable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public static class NatsConfParser
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses a NATS configuration string into a dictionary.
|
/// Parses a NATS configuration string into a dictionary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="data">Raw configuration text.</param>
|
||||||
public static Dictionary<string, object?> Parse(string data)
|
public static Dictionary<string, object?> Parse(string data)
|
||||||
{
|
{
|
||||||
var tokens = NatsConfLexer.Tokenize(data);
|
var tokens = NatsConfLexer.Tokenize(data);
|
||||||
@@ -40,11 +41,13 @@ public static class NatsConfParser
|
|||||||
/// Pedantic compatibility API (Go: ParseWithChecks).
|
/// Pedantic compatibility API (Go: ParseWithChecks).
|
||||||
/// Uses the same parser behavior as <see cref="Parse(string)"/>.
|
/// Uses the same parser behavior as <see cref="Parse(string)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="data">Raw configuration text.</param>
|
||||||
public static Dictionary<string, object?> ParseWithChecks(string data) => Parse(data);
|
public static Dictionary<string, object?> ParseWithChecks(string data) => Parse(data);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses a NATS configuration file into a dictionary.
|
/// Parses a NATS configuration file into a dictionary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="filePath">Path to the configuration file.</param>
|
||||||
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
||||||
ParseFile(filePath, includeDepth: 0);
|
ParseFile(filePath, includeDepth: 0);
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ public static class NatsConfParser
|
|||||||
/// Pedantic compatibility API (Go: ParseFileWithChecks).
|
/// Pedantic compatibility API (Go: ParseFileWithChecks).
|
||||||
/// Uses the same parser behavior as <see cref="ParseFile(string)"/>.
|
/// Uses the same parser behavior as <see cref="ParseFile(string)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="filePath">Path to the configuration file.</param>
|
||||||
public static Dictionary<string, object?> ParseFileWithChecks(string filePath) => ParseFile(filePath);
|
public static Dictionary<string, object?> ParseFileWithChecks(string filePath) => ParseFile(filePath);
|
||||||
|
|
||||||
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
||||||
@@ -68,6 +72,7 @@ public static class NatsConfParser
|
|||||||
/// Parses a NATS configuration file and returns the parsed config plus a
|
/// Parses a NATS configuration file and returns the parsed config plus a
|
||||||
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="filePath">Path to the configuration file.</param>
|
||||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
|
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
|
||||||
{
|
{
|
||||||
var rawBytes = File.ReadAllBytes(filePath);
|
var rawBytes = File.ReadAllBytes(filePath);
|
||||||
@@ -85,6 +90,7 @@ public static class NatsConfParser
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pedantic compatibility API (Go: ParseFileWithChecksDigest).
|
/// Pedantic compatibility API (Go: ParseFileWithChecksDigest).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="filePath">Path to the configuration file.</param>
|
||||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithChecksDigest(string filePath)
|
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithChecksDigest(string filePath)
|
||||||
{
|
{
|
||||||
var data = File.ReadAllText(filePath);
|
var data = File.ReadAllText(filePath);
|
||||||
@@ -204,13 +210,26 @@ public static class NatsConfParser
|
|||||||
// Pedantic-mode key token stack (Go parser field: ikeys).
|
// Pedantic-mode key token stack (Go parser field: ikeys).
|
||||||
private readonly List<Token> _itemKeys = new(4);
|
private readonly List<Token> _itemKeys = new(4);
|
||||||
|
|
||||||
|
/// <summary>Root parsed mapping for the current parser execution.</summary>
|
||||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates parser state for tokenized config input.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokens">Token stream from the config lexer.</param>
|
||||||
|
/// <param name="baseDir">Base directory used to resolve include paths.</param>
|
||||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
||||||
: this(tokens, baseDir, [], includeDepth: 0)
|
: this(tokens, baseDir, [], includeDepth: 0)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates parser state with explicit env-reference tracking and include depth.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokens">Token stream from the config lexer.</param>
|
||||||
|
/// <param name="baseDir">Base directory used to resolve include paths.</param>
|
||||||
|
/// <param name="envVarReferences">Shared environment-variable recursion guard set.</param>
|
||||||
|
/// <param name="includeDepth">Current include nesting depth.</param>
|
||||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences, int includeDepth)
|
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences, int includeDepth)
|
||||||
{
|
{
|
||||||
_tokens = tokens;
|
_tokens = tokens;
|
||||||
@@ -219,6 +238,9 @@ public static class NatsConfParser
|
|||||||
_includeDepth = includeDepth;
|
_includeDepth = includeDepth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the parse loop and builds <see cref="Mapping"/>.
|
||||||
|
/// </summary>
|
||||||
public void Run()
|
public void Run()
|
||||||
{
|
{
|
||||||
PushContext(Mapping);
|
PushContext(Mapping);
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public sealed class PedanticToken
|
|||||||
private readonly bool _usedVariable;
|
private readonly bool _usedVariable;
|
||||||
private readonly string _sourceFile;
|
private readonly string _sourceFile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a parser token wrapper that preserves resolved value and source metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">Raw lexer token captured from the configuration source.</param>
|
||||||
|
/// <param name="value">Optional parsed value override when token text has been normalized.</param>
|
||||||
|
/// <param name="usedVariable">Indicates whether this token originated from variable substitution.</param>
|
||||||
|
/// <param name="sourceFile">Source file path associated with this token, when available.</param>
|
||||||
public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "")
|
public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "")
|
||||||
{
|
{
|
||||||
_item = item;
|
_item = item;
|
||||||
@@ -44,15 +51,33 @@ public sealed class PedanticToken
|
|||||||
_sourceFile = sourceFile ?? string.Empty;
|
_sourceFile = sourceFile ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the token value into JSON, matching Go parser diagnostics formatting.
|
||||||
|
/// </summary>
|
||||||
public string MarshalJson() => JsonSerializer.Serialize(Value());
|
public string MarshalJson() => JsonSerializer.Serialize(Value());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the resolved token value, or raw token text when no typed value is stored.
|
||||||
|
/// </summary>
|
||||||
public object? Value() => _value ?? _item.Value;
|
public object? Value() => _value ?? _item.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the 1-based source line where the token was parsed.
|
||||||
|
/// </summary>
|
||||||
public int Line() => _item.Line;
|
public int Line() => _item.Line;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns whether variable interpolation contributed to this token.
|
||||||
|
/// </summary>
|
||||||
public bool IsUsedVariable() => _usedVariable;
|
public bool IsUsedVariable() => _usedVariable;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the source file path associated with this token.
|
||||||
|
/// </summary>
|
||||||
public string SourceFile() => _sourceFile;
|
public string SourceFile() => _sourceFile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the 1-based character position of the token on its source line.
|
||||||
|
/// </summary>
|
||||||
public int Position() => _item.Position;
|
public int Position() => _item.Position;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ public static class EventCompressor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compresses <paramref name="payload"/> using the requested <paramref name="compression"/>.
|
/// Compresses <paramref name="payload"/> using the requested <paramref name="compression"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="payload">Uncompressed event payload bytes.</param>
|
||||||
|
/// <param name="compression">Compression algorithm to apply for transport.</param>
|
||||||
public static byte[] Compress(ReadOnlySpan<byte> payload, EventCompressionType compression)
|
public static byte[] Compress(ReadOnlySpan<byte> payload, EventCompressionType compression)
|
||||||
{
|
{
|
||||||
if (payload.IsEmpty)
|
if (payload.IsEmpty)
|
||||||
@@ -104,6 +106,8 @@ public static class EventCompressor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decompresses <paramref name="compressed"/> using the selected <paramref name="compression"/>.
|
/// Decompresses <paramref name="compressed"/> using the selected <paramref name="compression"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="compressed">Compressed event payload bytes.</param>
|
||||||
|
/// <param name="compression">Encoding that was used when the payload was produced.</param>
|
||||||
public static byte[] Decompress(ReadOnlySpan<byte> compressed, EventCompressionType compression)
|
public static byte[] Decompress(ReadOnlySpan<byte> compressed, EventCompressionType compression)
|
||||||
{
|
{
|
||||||
if (compressed.IsEmpty)
|
if (compressed.IsEmpty)
|
||||||
@@ -150,6 +154,9 @@ public static class EventCompressor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compresses using <paramref name="compression"/> when payload size exceeds threshold.
|
/// Compresses using <paramref name="compression"/> when payload size exceeds threshold.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="payload">Raw event payload that may be compressed.</param>
|
||||||
|
/// <param name="compression">Preferred compression algorithm for eligible payloads.</param>
|
||||||
|
/// <param name="thresholdBytes">Minimum payload size required before compression is attempted.</param>
|
||||||
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
|
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
|
||||||
ReadOnlySpan<byte> payload,
|
ReadOnlySpan<byte> payload,
|
||||||
EventCompressionType compression,
|
EventCompressionType compression,
|
||||||
@@ -189,6 +196,7 @@ public static class EventCompressor
|
|||||||
/// Parses an HTTP Accept-Encoding value into a supported compression type.
|
/// Parses an HTTP Accept-Encoding value into a supported compression type.
|
||||||
/// Go reference: events.go getAcceptEncoding().
|
/// Go reference: events.go getAcceptEncoding().
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="acceptEncoding">Raw HTTP <c>Accept-Encoding</c> header value from the client.</param>
|
||||||
public static EventCompressionType GetAcceptEncoding(string? acceptEncoding)
|
public static EventCompressionType GetAcceptEncoding(string? acceptEncoding)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(acceptEncoding))
|
if (string.IsNullOrWhiteSpace(acceptEncoding))
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ public static class EventSubjects
|
|||||||
/// Callback signature for system message handlers.
|
/// Callback signature for system message handlers.
|
||||||
/// Maps to Go's sysMsgHandler type in events.go:109.
|
/// Maps to Go's sysMsgHandler type in events.go:109.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="sub">Subscription metadata that matched the incoming system message.</param>
|
||||||
|
/// <param name="client">Client connection context that delivered the message, when available.</param>
|
||||||
|
/// <param name="account">Owning account context for account-scoped system events.</param>
|
||||||
|
/// <param name="subject">System subject that triggered this callback.</param>
|
||||||
|
/// <param name="reply">Reply inbox subject for request/reply system handlers.</param>
|
||||||
|
/// <param name="headers">Optional message headers encoded by the publisher.</param>
|
||||||
|
/// <param name="message">Raw system advisory or request payload bytes.</param>
|
||||||
public delegate void SystemMessageHandler(
|
public delegate void SystemMessageHandler(
|
||||||
Subscription? sub,
|
Subscription? sub,
|
||||||
INatsClient? client,
|
INatsClient? client,
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ public static class GatewayCommands
|
|||||||
/// Wire format: GS+ {account} {subject}\r\n
|
/// Wire format: GS+ {account} {subject}\r\n
|
||||||
/// Go reference: gateway.go — sendGatewaySubsToGateway, RS+ propagation.
|
/// Go reference: gateway.go — sendGatewaySubsToGateway, RS+ propagation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Origin account used for gateway interest tracking.</param>
|
||||||
|
/// <param name="subject">Subject pattern being subscribed across clusters.</param>
|
||||||
public static byte[] FormatSub(string account, string subject)
|
public static byte[] FormatSub(string account, string subject)
|
||||||
=> Encoding.UTF8.GetBytes($"GS+ {account} {subject}\r\n");
|
=> Encoding.UTF8.GetBytes($"GS+ {account} {subject}\r\n");
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ public static class GatewayCommands
|
|||||||
/// Wire format: GS- {account} {subject}\r\n
|
/// Wire format: GS- {account} {subject}\r\n
|
||||||
/// Go reference: gateway.go — sendGatewayUnsubToGateway, RS- propagation.
|
/// Go reference: gateway.go — sendGatewayUnsubToGateway, RS- propagation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Origin account used for gateway interest tracking.</param>
|
||||||
|
/// <param name="subject">Subject pattern being removed from remote interest state.</param>
|
||||||
public static byte[] FormatUnsub(string account, string subject)
|
public static byte[] FormatUnsub(string account, string subject)
|
||||||
=> Encoding.UTF8.GetBytes($"GS- {account} {subject}\r\n");
|
=> Encoding.UTF8.GetBytes($"GS- {account} {subject}\r\n");
|
||||||
|
|
||||||
@@ -62,6 +66,8 @@ public static class GatewayCommands
|
|||||||
/// Mode: "O" for Optimistic (send everything), "I" for Interest-only.
|
/// Mode: "O" for Optimistic (send everything), "I" for Interest-only.
|
||||||
/// Go reference: gateway.go — switchAccountToInterestMode, GMODE command.
|
/// Go reference: gateway.go — switchAccountToInterestMode, GMODE command.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account whose cross-cluster routing mode is being updated.</param>
|
||||||
|
/// <param name="mode">Target gateway interest mode for that account.</param>
|
||||||
public static byte[] FormatMode(string account, GatewayInterestMode mode)
|
public static byte[] FormatMode(string account, GatewayInterestMode mode)
|
||||||
{
|
{
|
||||||
var modeStr = mode == GatewayInterestMode.InterestOnly ? "I" : "O";
|
var modeStr = mode == GatewayInterestMode.InterestOnly ? "I" : "O";
|
||||||
@@ -73,6 +79,7 @@ public static class GatewayCommands
|
|||||||
/// Returns null if the command prefix is unrecognized.
|
/// Returns null if the command prefix is unrecognized.
|
||||||
/// Go reference: gateway.go — processGatewayMsg command dispatch.
|
/// Go reference: gateway.go — processGatewayMsg command dispatch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="line">Raw protocol line prefix read from a gateway connection.</param>
|
||||||
public static GatewayCommandType? ParseCommandType(ReadOnlySpan<byte> line)
|
public static GatewayCommandType? ParseCommandType(ReadOnlySpan<byte> line)
|
||||||
{
|
{
|
||||||
if (line.StartsWith(InfoPrefix)) return GatewayCommandType.Info;
|
if (line.StartsWith(InfoPrefix)) return GatewayCommandType.Info;
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public sealed class GatewayInterestTracker
|
|||||||
// Per-account state: mode + no-interest set (Optimistic) or positive interest set (InterestOnly)
|
// Per-account state: mode + no-interest set (Optimistic) or positive interest set (InterestOnly)
|
||||||
private readonly ConcurrentDictionary<string, AccountState> _accounts = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, AccountState> _accounts = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a gateway interest tracker with a configurable mode-switch threshold.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noInterestThreshold">No-interest entry count that triggers InterestOnly mode.</param>
|
||||||
public GatewayInterestTracker(int noInterestThreshold = DefaultNoInterestThreshold)
|
public GatewayInterestTracker(int noInterestThreshold = DefaultNoInterestThreshold)
|
||||||
{
|
{
|
||||||
_noInterestThreshold = noInterestThreshold;
|
_noInterestThreshold = noInterestThreshold;
|
||||||
@@ -51,6 +55,7 @@ public sealed class GatewayInterestTracker
|
|||||||
/// Returns the current interest mode for the given account.
|
/// Returns the current interest mode for the given account.
|
||||||
/// Accounts default to Optimistic until the no-interest threshold is exceeded.
|
/// Accounts default to Optimistic until the no-interest threshold is exceeded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account name/identifier.</param>
|
||||||
public GatewayInterestMode GetMode(string account)
|
public GatewayInterestMode GetMode(string account)
|
||||||
=> _accounts.TryGetValue(account, out var state) ? state.Mode : GatewayInterestMode.Optimistic;
|
=> _accounts.TryGetValue(account, out var state) ? state.Mode : GatewayInterestMode.Optimistic;
|
||||||
|
|
||||||
@@ -58,6 +63,8 @@ public sealed class GatewayInterestTracker
|
|||||||
/// Track a positive interest (RS+ received from remote) for an account/subject.
|
/// Track a positive interest (RS+ received from remote) for an account/subject.
|
||||||
/// Go: gateway.go:1540 (processGatewayAccountSub — adds to interest set)
|
/// Go: gateway.go:1540 (processGatewayAccountSub — adds to interest set)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account name/identifier.</param>
|
||||||
|
/// <param name="subject">Subject or pattern with positive remote interest.</param>
|
||||||
public void TrackInterest(string account, string subject)
|
public void TrackInterest(string account, string subject)
|
||||||
{
|
{
|
||||||
var state = GetOrCreateState(account);
|
var state = GetOrCreateState(account);
|
||||||
@@ -83,6 +90,8 @@ public sealed class GatewayInterestTracker
|
|||||||
/// When the no-interest set crosses the threshold, switches to InterestOnly mode.
|
/// When the no-interest set crosses the threshold, switches to InterestOnly mode.
|
||||||
/// Go: gateway.go:1560 (processGatewayAccountUnsub — tracks no-interest, triggers switch)
|
/// Go: gateway.go:1560 (processGatewayAccountUnsub — tracks no-interest, triggers switch)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account name/identifier.</param>
|
||||||
|
/// <param name="subject">Subject or pattern that should be treated as no-interest.</param>
|
||||||
public void TrackNoInterest(string account, string subject)
|
public void TrackNoInterest(string account, string subject)
|
||||||
{
|
{
|
||||||
var state = GetOrCreateState(account);
|
var state = GetOrCreateState(account);
|
||||||
@@ -110,6 +119,8 @@ public sealed class GatewayInterestTracker
|
|||||||
/// for the given account and subject.
|
/// for the given account and subject.
|
||||||
/// Go: gateway.go:2900 (shouldForwardMsg — checks mode and interest)
|
/// Go: gateway.go:2900 (shouldForwardMsg — checks mode and interest)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account name/identifier.</param>
|
||||||
|
/// <param name="subject">Subject being considered for forwarding.</param>
|
||||||
public bool ShouldForward(string account, string subject)
|
public bool ShouldForward(string account, string subject)
|
||||||
{
|
{
|
||||||
if (!_accounts.TryGetValue(account, out var state))
|
if (!_accounts.TryGetValue(account, out var state))
|
||||||
@@ -141,6 +152,7 @@ public sealed class GatewayInterestTracker
|
|||||||
/// Called when the remote signals it is in interest-only mode.
|
/// Called when the remote signals it is in interest-only mode.
|
||||||
/// Go: gateway.go:1500 (switchToInterestOnlyMode)
|
/// Go: gateway.go:1500 (switchToInterestOnlyMode)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account name/identifier.</param>
|
||||||
public void SwitchToInterestOnly(string account)
|
public void SwitchToInterestOnly(string account)
|
||||||
{
|
{
|
||||||
var state = GetOrCreateState(account);
|
var state = GetOrCreateState(account);
|
||||||
@@ -179,6 +191,7 @@ public sealed class GatewayInterestTracker
|
|||||||
/// <summary>Per-account mutable state. All access must be under the instance lock.</summary>
|
/// <summary>Per-account mutable state. All access must be under the instance lock.</summary>
|
||||||
private sealed class AccountState
|
private sealed class AccountState
|
||||||
{
|
{
|
||||||
|
/// <summary>Current forwarding mode for this account.</summary>
|
||||||
public GatewayInterestMode Mode { get; set; } = GatewayInterestMode.Optimistic;
|
public GatewayInterestMode Mode { get; set; } = GatewayInterestMode.Optimistic;
|
||||||
|
|
||||||
/// <summary>Subjects with no remote interest (used in Optimistic mode).</summary>
|
/// <summary>Subjects with no remote interest (used in Optimistic mode).</summary>
|
||||||
|
|||||||
@@ -5,18 +5,45 @@ namespace NATS.Server;
|
|||||||
|
|
||||||
public interface INatsClient
|
public interface INatsClient
|
||||||
{
|
{
|
||||||
|
/// <summary>Unique server-assigned client identifier.</summary>
|
||||||
ulong Id { get; }
|
ulong Id { get; }
|
||||||
|
/// <summary>Client kind (client, route, gateway, leaf, system, etc.).</summary>
|
||||||
ClientKind Kind { get; }
|
ClientKind Kind { get; }
|
||||||
|
/// <summary>Whether this client is server-internal and not socket-backed.</summary>
|
||||||
bool IsInternal => Kind.IsInternal();
|
bool IsInternal => Kind.IsInternal();
|
||||||
|
/// <summary>Account context associated with this client.</summary>
|
||||||
Account? Account { get; }
|
Account? Account { get; }
|
||||||
|
/// <summary>Parsed CONNECT options for this client when available.</summary>
|
||||||
ClientOptions? ClientOpts { get; }
|
ClientOptions? ClientOpts { get; }
|
||||||
|
/// <summary>Resolved publish/subscribe permissions for this client.</summary>
|
||||||
ClientPermissions? Permissions { get; }
|
ClientPermissions? Permissions { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a protocol message to a subscription with immediate flush semantics.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Delivery subject sent to the client.</param>
|
||||||
|
/// <param name="sid">Subscription identifier receiving the message.</param>
|
||||||
|
/// <param name="replyTo">Optional reply subject for request-reply flows.</param>
|
||||||
|
/// <param name="headers">Serialized NATS headers payload.</param>
|
||||||
|
/// <param name="payload">Message payload bytes.</param>
|
||||||
void SendMessage(string subject, string sid, string? replyTo,
|
void SendMessage(string subject, string sid, string? replyTo,
|
||||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
|
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a protocol message without forcing an immediate flush.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Delivery subject sent to the client.</param>
|
||||||
|
/// <param name="sid">Subscription identifier receiving the message.</param>
|
||||||
|
/// <param name="replyTo">Optional reply subject for request-reply flows.</param>
|
||||||
|
/// <param name="headers">Serialized NATS headers payload.</param>
|
||||||
|
/// <param name="payload">Message payload bytes.</param>
|
||||||
void SendMessageNoFlush(string subject, string sid, string? replyTo,
|
void SendMessageNoFlush(string subject, string sid, string? replyTo,
|
||||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
|
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
|
||||||
|
/// <summary>Signals that queued outbound bytes should be flushed.</summary>
|
||||||
void SignalFlush();
|
void SignalFlush();
|
||||||
|
/// <summary>Queues outbound protocol bytes for asynchronous write-loop transmission.</summary>
|
||||||
|
/// <param name="data">Serialized protocol bytes to queue.</param>
|
||||||
bool QueueOutbound(ReadOnlyMemory<byte> data);
|
bool QueueOutbound(ReadOnlyMemory<byte> data);
|
||||||
|
/// <summary>Removes a subscription by subscription identifier.</summary>
|
||||||
|
/// <param name="sid">Subscription identifier to remove.</param>
|
||||||
void RemoveSubscription(string sid);
|
void RemoveSubscription(string sid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ public sealed class OutboundBufferPool
|
|||||||
private long _returnCount;
|
private long _returnCount;
|
||||||
private long _broadcastCount;
|
private long _broadcastCount;
|
||||||
|
|
||||||
|
/// <summary>Total buffer rent operations served by the pool.</summary>
|
||||||
public long RentCount => Interlocked.Read(ref _rentCount);
|
public long RentCount => Interlocked.Read(ref _rentCount);
|
||||||
|
/// <summary>Total buffer return operations accepted by the pool.</summary>
|
||||||
public long ReturnCount => Interlocked.Read(ref _returnCount);
|
public long ReturnCount => Interlocked.Read(ref _returnCount);
|
||||||
|
/// <summary>Total broadcast-drain operations performed.</summary>
|
||||||
public long BroadcastCount => Interlocked.Read(ref _broadcastCount);
|
public long BroadcastCount => Interlocked.Read(ref _broadcastCount);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -36,6 +39,7 @@ public sealed class OutboundBufferPool
|
|||||||
/// <paramref name="size"/> bytes. Tries the internal pool first; falls back to
|
/// <paramref name="size"/> bytes. Tries the internal pool first; falls back to
|
||||||
/// <see cref="MemoryPool{T}.Shared"/>.
|
/// <see cref="MemoryPool{T}.Shared"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="size">Minimum required buffer size.</param>
|
||||||
public IMemoryOwner<byte> Rent(int size)
|
public IMemoryOwner<byte> Rent(int size)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _rentCount);
|
Interlocked.Increment(ref _rentCount);
|
||||||
@@ -70,6 +74,7 @@ public sealed class OutboundBufferPool
|
|||||||
/// <paramref name="size"/> bytes. The caller is responsible for calling
|
/// <paramref name="size"/> bytes. The caller is responsible for calling
|
||||||
/// <see cref="ReturnBuffer"/> when finished.
|
/// <see cref="ReturnBuffer"/> when finished.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="size">Minimum required buffer size.</param>
|
||||||
public byte[] RentBuffer(int size)
|
public byte[] RentBuffer(int size)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _rentCount);
|
Interlocked.Increment(ref _rentCount);
|
||||||
@@ -94,6 +99,7 @@ public sealed class OutboundBufferPool
|
|||||||
/// Returns <paramref name="buffer"/> to the appropriate tier so it can be
|
/// Returns <paramref name="buffer"/> to the appropriate tier so it can be
|
||||||
/// reused by a subsequent <see cref="RentBuffer"/> call.
|
/// reused by a subsequent <see cref="RentBuffer"/> call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="buffer">Buffer previously rented from this pool.</param>
|
||||||
public void ReturnBuffer(byte[] buffer)
|
public void ReturnBuffer(byte[] buffer)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _returnCount);
|
Interlocked.Increment(ref _returnCount);
|
||||||
@@ -128,6 +134,8 @@ public sealed class OutboundBufferPool
|
|||||||
///
|
///
|
||||||
/// Go reference: client.go — broadcast flush coalescing for fan-out.
|
/// Go reference: client.go — broadcast flush coalescing for fan-out.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="pendingWrites">Pending write segments to coalesce.</param>
|
||||||
|
/// <param name="destination">Destination buffer receiving the concatenated payloads.</param>
|
||||||
public int BroadcastDrain(IReadOnlyList<ReadOnlyMemory<byte>> pendingWrites, byte[] destination)
|
public int BroadcastDrain(IReadOnlyList<ReadOnlyMemory<byte>> pendingWrites, byte[] destination)
|
||||||
{
|
{
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
@@ -144,6 +152,7 @@ public sealed class OutboundBufferPool
|
|||||||
/// Returns the total number of bytes needed to coalesce all
|
/// Returns the total number of bytes needed to coalesce all
|
||||||
/// <paramref name="pendingWrites"/> into a single buffer.
|
/// <paramref name="pendingWrites"/> into a single buffer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="pendingWrites">Pending write segments to size.</param>
|
||||||
public static int CalculateBroadcastSize(IReadOnlyList<ReadOnlyMemory<byte>> pendingWrites)
|
public static int CalculateBroadcastSize(IReadOnlyList<ReadOnlyMemory<byte>> pendingWrites)
|
||||||
{
|
{
|
||||||
var total = 0;
|
var total = 0;
|
||||||
@@ -164,15 +173,22 @@ public sealed class OutboundBufferPool
|
|||||||
private readonly ConcurrentBag<byte[]> _pool;
|
private readonly ConcurrentBag<byte[]> _pool;
|
||||||
private byte[]? _buffer;
|
private byte[]? _buffer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pooled memory owner backed by a reusable byte array.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="buffer">Rented backing buffer.</param>
|
||||||
|
/// <param name="pool">Pool to return the buffer to on disposal.</param>
|
||||||
public PooledMemoryOwner(byte[] buffer, ConcurrentBag<byte[]> pool)
|
public PooledMemoryOwner(byte[] buffer, ConcurrentBag<byte[]> pool)
|
||||||
{
|
{
|
||||||
_buffer = buffer;
|
_buffer = buffer;
|
||||||
_pool = pool;
|
_pool = pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Memory view over the currently owned buffer.</summary>
|
||||||
public Memory<byte> Memory =>
|
public Memory<byte> Memory =>
|
||||||
_buffer is { } b ? b.AsMemory() : Memory<byte>.Empty;
|
_buffer is { } b ? b.AsMemory() : Memory<byte>.Empty;
|
||||||
|
|
||||||
|
/// <summary>Returns the owned buffer to the originating pool.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref _buffer, null) is { } b)
|
if (Interlocked.Exchange(ref _buffer, null) is { } b)
|
||||||
|
|||||||
@@ -4,11 +4,30 @@ namespace NATS.Server.Imports;
|
|||||||
|
|
||||||
public sealed class ExportAuth
|
public sealed class ExportAuth
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether importers must present a token for access.
|
||||||
|
/// </summary>
|
||||||
public bool TokenRequired { get; init; }
|
public bool TokenRequired { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the account-token subject position used for legacy tokenized export patterns.
|
||||||
|
/// </summary>
|
||||||
public uint AccountPosition { get; init; }
|
public uint AccountPosition { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets explicit account names permitted to import this export.
|
||||||
|
/// </summary>
|
||||||
public HashSet<string>? ApprovedAccounts { get; init; }
|
public HashSet<string>? ApprovedAccounts { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets accounts revoked from import access, mapped to revocation timestamps.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<string, long>? RevokedAccounts { get; init; }
|
public Dictionary<string, long>? RevokedAccounts { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the specified account is currently authorized for this export.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">Importing account requesting access.</param>
|
||||||
public bool IsAuthorized(Account account)
|
public bool IsAuthorized(Account account)
|
||||||
{
|
{
|
||||||
if (RevokedAccounts != null && RevokedAccounts.ContainsKey(account.Name))
|
if (RevokedAccounts != null && RevokedAccounts.ContainsKey(account.Name))
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ namespace NATS.Server.Imports;
|
|||||||
|
|
||||||
public sealed class ExportMap
|
public sealed class ExportMap
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets stream exports keyed by exported subject.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<string, StreamExport> Streams { get; } = new(StringComparer.Ordinal);
|
public Dictionary<string, StreamExport> Streams { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets service exports keyed by exported subject.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<string, ServiceExport> Services { get; } = new(StringComparer.Ordinal);
|
public Dictionary<string, ServiceExport> Services { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets temporary response imports keyed by generated reply prefix.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<string, ServiceImport> Responses { get; } = new(StringComparer.Ordinal);
|
public Dictionary<string, ServiceImport> Responses { get; } = new(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ namespace NATS.Server.Imports;
|
|||||||
|
|
||||||
public sealed class ImportMap
|
public sealed class ImportMap
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets stream import definitions configured for the account.
|
||||||
|
/// </summary>
|
||||||
public List<StreamImport> Streams { get; } = [];
|
public List<StreamImport> Streams { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets service import definitions grouped by source subject.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<string, List<ServiceImport>> Services { get; } = new(StringComparer.Ordinal);
|
public Dictionary<string, List<ServiceImport>> Services { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a service import under its source subject key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="si">Service import definition to add.</param>
|
||||||
public void AddServiceImport(ServiceImport si)
|
public void AddServiceImport(ServiceImport si)
|
||||||
{
|
{
|
||||||
if (!Services.TryGetValue(si.From, out var list))
|
if (!Services.TryGetValue(si.From, out var list))
|
||||||
|
|||||||
@@ -2,29 +2,57 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace NATS.Server.Imports;
|
namespace NATS.Server.Imports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialized payload published for exported-service latency advisories.
|
||||||
|
/// </summary>
|
||||||
public sealed class ServiceLatencyMsg
|
public sealed class ServiceLatencyMsg
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the schema identifier used by consumers to decode this metric event.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public string Type { get; set; } = "io.nats.server.metric.v1.service_latency";
|
public string Type { get; set; } = "io.nats.server.metric.v1.service_latency";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the account or identity that initiated the service request.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("requestor")]
|
[JsonPropertyName("requestor")]
|
||||||
public string Requestor { get; set; } = string.Empty;
|
public string Requestor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the service identity that responded to the request.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("responder")]
|
[JsonPropertyName("responder")]
|
||||||
public string Responder { get; set; } = string.Empty;
|
public string Responder { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the service response status code reported in the advisory.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("status")]
|
[JsonPropertyName("status")]
|
||||||
public int Status { get; set; } = 200;
|
public int Status { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets service execution latency in nanoseconds on the responder side.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("svc_latency")]
|
[JsonPropertyName("svc_latency")]
|
||||||
public long ServiceLatencyNanos { get; set; }
|
public long ServiceLatencyNanos { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets end-to-end request latency in nanoseconds from requestor to response.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("total_latency")]
|
[JsonPropertyName("total_latency")]
|
||||||
public long TotalLatencyNanos { get; set; }
|
public long TotalLatencyNanos { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sampling and payload helpers for service import latency metrics.
|
||||||
|
/// </summary>
|
||||||
public static class LatencyTracker
|
public static class LatencyTracker
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a request should emit latency telemetry based on configured sampling.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="latency">Service latency sampling configuration from the export definition.</param>
|
||||||
public static bool ShouldSample(ServiceLatency latency)
|
public static bool ShouldSample(ServiceLatency latency)
|
||||||
{
|
{
|
||||||
if (latency.SamplingPercentage <= 0) return false;
|
if (latency.SamplingPercentage <= 0) return false;
|
||||||
@@ -32,6 +60,13 @@ public static class LatencyTracker
|
|||||||
return Random.Shared.Next(100) < latency.SamplingPercentage;
|
return Random.Shared.Next(100) < latency.SamplingPercentage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a service latency advisory payload from measured service and end-to-end durations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestor">Identity of the requesting account or client.</param>
|
||||||
|
/// <param name="responder">Identity of the exported service that handled the request.</param>
|
||||||
|
/// <param name="serviceLatency">Time spent processing the request by the service itself.</param>
|
||||||
|
/// <param name="totalLatency">Full request round-trip latency as observed by the server.</param>
|
||||||
public static ServiceLatencyMsg BuildLatencyMsg(
|
public static ServiceLatencyMsg BuildLatencyMsg(
|
||||||
string requestor, string responder,
|
string requestor, string responder,
|
||||||
TimeSpan serviceLatency, TimeSpan totalLatency)
|
TimeSpan serviceLatency, TimeSpan totalLatency)
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public static class ResponseRouter
|
|||||||
/// Creates a response service import that maps the generated reply prefix
|
/// Creates a response service import that maps the generated reply prefix
|
||||||
/// back to the original reply subject on the requesting account.
|
/// back to the original reply subject on the requesting account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="exporterAccount">Exporter account that stores temporary response imports.</param>
|
||||||
|
/// <param name="originalImport">Original service import that triggered the response path.</param>
|
||||||
|
/// <param name="originalReply">Original reply subject to route responder messages back to.</param>
|
||||||
public static ServiceImport CreateResponseImport(
|
public static ServiceImport CreateResponseImport(
|
||||||
Account exporterAccount,
|
Account exporterAccount,
|
||||||
ServiceImport originalImport,
|
ServiceImport originalImport,
|
||||||
@@ -57,6 +60,9 @@ public static class ResponseRouter
|
|||||||
/// For Singleton responses, this is called after the first reply is delivered.
|
/// For Singleton responses, this is called after the first reply is delivered.
|
||||||
/// For Streamed/Chunked, it is called when the response stream ends.
|
/// For Streamed/Chunked, it is called when the response stream ends.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account that owns the temporary response import map.</param>
|
||||||
|
/// <param name="replyPrefix">Generated reply prefix key to remove.</param>
|
||||||
|
/// <param name="responseSi">Response service import being cleaned up.</param>
|
||||||
public static void CleanupResponse(Account account, string replyPrefix, ServiceImport responseSi)
|
public static void CleanupResponse(Account account, string replyPrefix, ServiceImport responseSi)
|
||||||
{
|
{
|
||||||
account.Exports.Responses.Remove(replyPrefix);
|
account.Exports.Responses.Remove(replyPrefix);
|
||||||
|
|||||||
@@ -4,10 +4,33 @@ namespace NATS.Server.Imports;
|
|||||||
|
|
||||||
public sealed class ServiceExport
|
public sealed class ServiceExport
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets authorization rules controlling which accounts may import this service.
|
||||||
|
/// </summary>
|
||||||
public ExportAuth Auth { get; init; } = new();
|
public ExportAuth Auth { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the exporting account that owns this service definition.
|
||||||
|
/// </summary>
|
||||||
public Account? Account { get; init; }
|
public Account? Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the response mode expected from service responders (singleton or streamed).
|
||||||
|
/// </summary>
|
||||||
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
|
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the threshold used for service latency advisories and slow-response tracking.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan ResponseThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
public TimeSpan ResponseThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets optional service latency sampling configuration for exported service calls.
|
||||||
|
/// </summary>
|
||||||
public ServiceLatency? Latency { get; init; }
|
public ServiceLatency? Latency { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether distributed tracing headers are allowed for this service.
|
||||||
|
/// </summary>
|
||||||
public bool AllowTrace { get; init; }
|
public bool AllowTrace { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,30 @@ namespace NATS.Server.Imports;
|
|||||||
|
|
||||||
public sealed class ServiceImport
|
public sealed class ServiceImport
|
||||||
{
|
{
|
||||||
|
/// <summary>Account that receives requests after the service import mapping is applied.</summary>
|
||||||
public required Account DestinationAccount { get; init; }
|
public required Account DestinationAccount { get; init; }
|
||||||
|
/// <summary>Source subject exposed to the importing account.</summary>
|
||||||
public required string From { get; init; }
|
public required string From { get; init; }
|
||||||
|
/// <summary>Destination subject routed to the exporting account/service.</summary>
|
||||||
public required string To { get; init; }
|
public required string To { get; init; }
|
||||||
|
/// <summary>Optional subject transform applied when forwarding imported requests.</summary>
|
||||||
public SubjectTransform? Transform { get; init; }
|
public SubjectTransform? Transform { get; init; }
|
||||||
|
/// <summary>Export definition backing this import relationship.</summary>
|
||||||
public ServiceExport? Export { get; init; }
|
public ServiceExport? Export { get; init; }
|
||||||
|
/// <summary>Response behavior for imported service replies (singleton/stream/chunked).</summary>
|
||||||
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
|
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
|
||||||
|
/// <summary>Subscription identifier bytes used for internal routing bookkeeping.</summary>
|
||||||
public byte[]? Sid { get; set; }
|
public byte[]? Sid { get; set; }
|
||||||
|
/// <summary>Whether this import currently represents a generated response mapping.</summary>
|
||||||
public bool IsResponse { get; init; }
|
public bool IsResponse { get; init; }
|
||||||
|
/// <summary>Whether forwarding should use PUB semantics instead of request/reply.</summary>
|
||||||
public bool UsePub { get; init; }
|
public bool UsePub { get; init; }
|
||||||
|
/// <summary>Whether the import definition has been invalidated.</summary>
|
||||||
public bool Invalid { get; set; }
|
public bool Invalid { get; set; }
|
||||||
|
/// <summary>Whether this import can be shared across accounts/connections.</summary>
|
||||||
public bool Share { get; init; }
|
public bool Share { get; init; }
|
||||||
|
/// <summary>Whether service latency/tracking metrics are enabled for this import.</summary>
|
||||||
public bool Tracking { get; init; }
|
public bool Tracking { get; init; }
|
||||||
|
/// <summary>Last update timestamp stored as UTC ticks.</summary>
|
||||||
public long TimestampTicks { get; set; }
|
public long TimestampTicks { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,33 @@ namespace NATS.Server.Imports;
|
|||||||
|
|
||||||
public sealed class StreamImport
|
public sealed class StreamImport
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the exporting account that owns the imported stream subjects.
|
||||||
|
/// </summary>
|
||||||
public required Account SourceAccount { get; init; }
|
public required Account SourceAccount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the source subject pattern in the exporting account.
|
||||||
|
/// </summary>
|
||||||
public required string From { get; init; }
|
public required string From { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the destination subject pattern mapped into the importing account.
|
||||||
|
/// </summary>
|
||||||
public required string To { get; init; }
|
public required string To { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the optional transform applied while remapping imported stream subjects.
|
||||||
|
/// </summary>
|
||||||
public SubjectTransform? Transform { get; init; }
|
public SubjectTransform? Transform { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the import is restricted to publish operations.
|
||||||
|
/// </summary>
|
||||||
public bool UsePub { get; init; }
|
public bool UsePub { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this import has been marked invalid by validation logic.
|
||||||
|
/// </summary>
|
||||||
public bool Invalid { get; set; }
|
public bool Invalid { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,11 +182,17 @@ internal sealed class Leaf<T> : INode
|
|||||||
=> Parts.MatchPartsAgainstFragment(parts, Suffix);
|
=> Parts.MatchPartsAgainstFragment(parts, Suffix);
|
||||||
|
|
||||||
// These should not be called on a leaf.
|
// These should not be called on a leaf.
|
||||||
|
/// <inheritdoc />
|
||||||
public void SetPrefix(ReadOnlySpan<byte> pre) => throw new InvalidOperationException("setPrefix called on leaf");
|
public void SetPrefix(ReadOnlySpan<byte> pre) => throw new InvalidOperationException("setPrefix called on leaf");
|
||||||
|
/// <inheritdoc />
|
||||||
public void AddChild(byte c, INode n) => throw new InvalidOperationException("addChild called on leaf");
|
public void AddChild(byte c, INode n) => throw new InvalidOperationException("addChild called on leaf");
|
||||||
|
/// <inheritdoc />
|
||||||
public ChildRef? FindChild(byte c) => throw new InvalidOperationException("findChild called on leaf");
|
public ChildRef? FindChild(byte c) => throw new InvalidOperationException("findChild called on leaf");
|
||||||
|
/// <inheritdoc />
|
||||||
public INode Grow() => throw new InvalidOperationException("grow called on leaf");
|
public INode Grow() => throw new InvalidOperationException("grow called on leaf");
|
||||||
|
/// <inheritdoc />
|
||||||
public void DeleteChild(byte c) => throw new InvalidOperationException("deleteChild called on leaf");
|
public void DeleteChild(byte c) => throw new InvalidOperationException("deleteChild called on leaf");
|
||||||
|
/// <inheritdoc />
|
||||||
public INode? Shrink() => throw new InvalidOperationException("shrink called on leaf");
|
public INode? Shrink() => throw new InvalidOperationException("shrink called on leaf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ internal static class Parts
|
|||||||
/// Returns the pivot byte at the given position, or NoPivot if past end.
|
/// Returns the pivot byte at the given position, or NoPivot if past end.
|
||||||
/// Go reference: server/stree/util.go:pivot
|
/// Go reference: server/stree/util.go:pivot
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="subject">Subject bytes being inspected in the ART path.</param>
|
||||||
|
/// <param name="pos">Zero-based byte position to read from <paramref name="subject"/>.</param>
|
||||||
internal static byte Pivot(ReadOnlySpan<byte> subject, int pos)
|
internal static byte Pivot(ReadOnlySpan<byte> subject, int pos)
|
||||||
{
|
{
|
||||||
if (pos >= subject.Length) return NoPivot;
|
if (pos >= subject.Length) return NoPivot;
|
||||||
@@ -30,6 +32,8 @@ internal static class Parts
|
|||||||
/// Returns the length of the common prefix between two byte spans.
|
/// Returns the length of the common prefix between two byte spans.
|
||||||
/// Go reference: server/stree/util.go:commonPrefixLen
|
/// Go reference: server/stree/util.go:commonPrefixLen
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="s1">First subject fragment.</param>
|
||||||
|
/// <param name="s2">Second subject fragment.</param>
|
||||||
internal static int CommonPrefixLen(ReadOnlySpan<byte> s1, ReadOnlySpan<byte> s2)
|
internal static int CommonPrefixLen(ReadOnlySpan<byte> s1, ReadOnlySpan<byte> s2)
|
||||||
{
|
{
|
||||||
var limit = Math.Min(s1.Length, s2.Length);
|
var limit = Math.Min(s1.Length, s2.Length);
|
||||||
@@ -44,6 +48,7 @@ internal static class Parts
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copy bytes helper.
|
/// Copy bytes helper.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="src">Source bytes to clone into a managed array.</param>
|
||||||
internal static byte[] CopyBytes(ReadOnlySpan<byte> src)
|
internal static byte[] CopyBytes(ReadOnlySpan<byte> src)
|
||||||
{
|
{
|
||||||
if (src.Length == 0) return [];
|
if (src.Length == 0) return [];
|
||||||
@@ -54,6 +59,7 @@ internal static class Parts
|
|||||||
/// Break a filter subject into parts based on wildcards (pwc '*' and fwc '>').
|
/// Break a filter subject into parts based on wildcards (pwc '*' and fwc '>').
|
||||||
/// Go reference: server/stree/parts.go:genParts
|
/// Go reference: server/stree/parts.go:genParts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="filter">Subscription filter subject that may include <c>*</c> or <c>></c> wildcards.</param>
|
||||||
internal static ReadOnlyMemory<byte>[] GenParts(ReadOnlySpan<byte> filter)
|
internal static ReadOnlyMemory<byte>[] GenParts(ReadOnlySpan<byte> filter)
|
||||||
{
|
{
|
||||||
var parts = new List<ReadOnlyMemory<byte>>();
|
var parts = new List<ReadOnlyMemory<byte>>();
|
||||||
@@ -142,6 +148,8 @@ internal static class Parts
|
|||||||
/// Match parts against a fragment (prefix for nodes or suffix for leaves).
|
/// Match parts against a fragment (prefix for nodes or suffix for leaves).
|
||||||
/// Go reference: server/stree/parts.go:matchParts
|
/// Go reference: server/stree/parts.go:matchParts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="parts">Pre-tokenized wildcard and literal parts generated from a subject filter.</param>
|
||||||
|
/// <param name="frag">Current subject fragment being matched within the ART traversal.</param>
|
||||||
internal static (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchPartsAgainstFragment(
|
internal static (ReadOnlyMemory<byte>[] RemainingParts, bool Matched) MatchPartsAgainstFragment(
|
||||||
ReadOnlyMemory<byte>[] parts, ReadOnlySpan<byte> frag)
|
ReadOnlyMemory<byte>[] parts, ReadOnlySpan<byte> frag)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ public sealed class AdvisoryPublisher
|
|||||||
private readonly Action<string, object> _publishAction;
|
private readonly Action<string, object> _publishAction;
|
||||||
private long _publishCount;
|
private long _publishCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an advisory publisher that emits advisory payloads through the provided callback.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="publishAction">Callback that publishes advisory objects to subjects.</param>
|
||||||
public AdvisoryPublisher(Action<string, object> publishAction)
|
public AdvisoryPublisher(Action<string, object> publishAction)
|
||||||
{
|
{
|
||||||
_publishAction = publishAction;
|
_publishAction = publishAction;
|
||||||
@@ -25,6 +29,8 @@ public sealed class AdvisoryPublisher
|
|||||||
/// Publishes a stream created advisory.
|
/// Publishes a stream created advisory.
|
||||||
/// Go reference: jetstream_api.go — advisory on stream creation.
|
/// Go reference: jetstream_api.go — advisory on stream creation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="streamName">Name of the created stream.</param>
|
||||||
|
/// <param name="detail">Optional stream-specific detail payload.</param>
|
||||||
public void StreamCreated(string streamName, object? detail = null)
|
public void StreamCreated(string streamName, object? detail = null)
|
||||||
{
|
{
|
||||||
var subject = string.Format(Events.EventSubjects.JsAdvisoryStreamCreated, streamName);
|
var subject = string.Format(Events.EventSubjects.JsAdvisoryStreamCreated, streamName);
|
||||||
@@ -41,6 +47,7 @@ public sealed class AdvisoryPublisher
|
|||||||
/// Publishes a stream deleted advisory.
|
/// Publishes a stream deleted advisory.
|
||||||
/// Go reference: jetstream_api.go — advisory on stream deletion.
|
/// Go reference: jetstream_api.go — advisory on stream deletion.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="streamName">Name of the deleted stream.</param>
|
||||||
public void StreamDeleted(string streamName)
|
public void StreamDeleted(string streamName)
|
||||||
{
|
{
|
||||||
var subject = string.Format(Events.EventSubjects.JsAdvisoryStreamDeleted, streamName);
|
var subject = string.Format(Events.EventSubjects.JsAdvisoryStreamDeleted, streamName);
|
||||||
@@ -56,6 +63,8 @@ public sealed class AdvisoryPublisher
|
|||||||
/// Publishes a stream updated advisory.
|
/// Publishes a stream updated advisory.
|
||||||
/// Go reference: jetstream_api.go — advisory on stream config update.
|
/// Go reference: jetstream_api.go — advisory on stream config update.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="streamName">Name of the updated stream.</param>
|
||||||
|
/// <param name="detail">Optional update detail payload.</param>
|
||||||
public void StreamUpdated(string streamName, object? detail = null)
|
public void StreamUpdated(string streamName, object? detail = null)
|
||||||
{
|
{
|
||||||
var subject = string.Format(Events.EventSubjects.JsAdvisoryStreamUpdated, streamName);
|
var subject = string.Format(Events.EventSubjects.JsAdvisoryStreamUpdated, streamName);
|
||||||
@@ -72,6 +81,8 @@ public sealed class AdvisoryPublisher
|
|||||||
/// Publishes a consumer created advisory.
|
/// Publishes a consumer created advisory.
|
||||||
/// Go reference: jetstream_api.go — advisory on consumer creation.
|
/// Go reference: jetstream_api.go — advisory on consumer creation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="streamName">Parent stream name.</param>
|
||||||
|
/// <param name="consumerName">Created consumer name.</param>
|
||||||
public void ConsumerCreated(string streamName, string consumerName)
|
public void ConsumerCreated(string streamName, string consumerName)
|
||||||
{
|
{
|
||||||
var subject = string.Format(Events.EventSubjects.JsAdvisoryConsumerCreated, streamName, consumerName);
|
var subject = string.Format(Events.EventSubjects.JsAdvisoryConsumerCreated, streamName, consumerName);
|
||||||
@@ -88,6 +99,8 @@ public sealed class AdvisoryPublisher
|
|||||||
/// Publishes a consumer deleted advisory.
|
/// Publishes a consumer deleted advisory.
|
||||||
/// Go reference: jetstream_api.go — advisory on consumer deletion.
|
/// Go reference: jetstream_api.go — advisory on consumer deletion.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="streamName">Parent stream name.</param>
|
||||||
|
/// <param name="consumerName">Deleted consumer name.</param>
|
||||||
public void ConsumerDeleted(string streamName, string consumerName)
|
public void ConsumerDeleted(string streamName, string consumerName)
|
||||||
{
|
{
|
||||||
var subject = string.Format(Events.EventSubjects.JsAdvisoryConsumerDeleted, streamName, consumerName);
|
var subject = string.Format(Events.EventSubjects.JsAdvisoryConsumerDeleted, streamName, consumerName);
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public sealed class ApiRateLimiter : IDisposable
|
|||||||
private readonly TimeSpan _dedupTtl;
|
private readonly TimeSpan _dedupTtl;
|
||||||
private readonly int _maxConcurrent;
|
private readonly int _maxConcurrent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a JetStream API limiter for request concurrency and short-window deduplication.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="maxConcurrent">Maximum concurrent API handlers allowed before rejecting new requests.</param>
|
||||||
|
/// <param name="dedupTtl">TTL window for request-id dedup cache entries.</param>
|
||||||
public ApiRateLimiter(int maxConcurrent = 256, TimeSpan? dedupTtl = null)
|
public ApiRateLimiter(int maxConcurrent = 256, TimeSpan? dedupTtl = null)
|
||||||
{
|
{
|
||||||
_maxConcurrent = maxConcurrent;
|
_maxConcurrent = maxConcurrent;
|
||||||
@@ -33,6 +38,7 @@ public sealed class ApiRateLimiter : IDisposable
|
|||||||
/// Go reference: jetstream_api.go — non-blocking semaphore acquire; request is rejected
|
/// Go reference: jetstream_api.go — non-blocking semaphore acquire; request is rejected
|
||||||
/// immediately if no slots are available rather than queuing indefinitely.
|
/// immediately if no slots are available rather than queuing indefinitely.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token for the slot acquisition attempt.</param>
|
||||||
public async Task<bool> TryAcquireAsync(CancellationToken ct = default)
|
public async Task<bool> TryAcquireAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _semaphore.WaitAsync(0, ct);
|
return await _semaphore.WaitAsync(0, ct);
|
||||||
@@ -51,6 +57,7 @@ public sealed class ApiRateLimiter : IDisposable
|
|||||||
/// Returns the cached response if found and not expired, null otherwise.
|
/// Returns the cached response if found and not expired, null otherwise.
|
||||||
/// Go reference: jetstream_api.go — dedup cache is keyed by Nats-Msg-Id header value.
|
/// Go reference: jetstream_api.go — dedup cache is keyed by Nats-Msg-Id header value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="requestId">Message-id dedup key from the request.</param>
|
||||||
public JetStreamApiResponse? GetCachedResponse(string? requestId)
|
public JetStreamApiResponse? GetCachedResponse(string? requestId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(requestId))
|
if (string.IsNullOrEmpty(requestId))
|
||||||
@@ -73,6 +80,8 @@ public sealed class ApiRateLimiter : IDisposable
|
|||||||
/// Go reference: jetstream_api.go — response is stored with a timestamp so that
|
/// Go reference: jetstream_api.go — response is stored with a timestamp so that
|
||||||
/// subsequent requests with the same Nats-Msg-Id within the TTL window get the same result.
|
/// subsequent requests with the same Nats-Msg-Id within the TTL window get the same result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="requestId">Message-id dedup key from the request.</param>
|
||||||
|
/// <param name="response">Response payload to reuse for duplicate requests within the dedup window.</param>
|
||||||
public void CacheResponse(string? requestId, JetStreamApiResponse response)
|
public void CacheResponse(string? requestId, JetStreamApiResponse response)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(requestId))
|
if (string.IsNullOrEmpty(requestId))
|
||||||
@@ -99,6 +108,9 @@ public sealed class ApiRateLimiter : IDisposable
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases semaphore resources held by the rate limiter.
|
||||||
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_semaphore.Dispose();
|
_semaphore.Dispose();
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public sealed class ClusteredRequestProcessor
|
|||||||
private readonly TimeSpan _timeout;
|
private readonly TimeSpan _timeout;
|
||||||
private int _pendingCount;
|
private int _pendingCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a clustered request processor with a configurable wait timeout.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeout">Optional timeout for waiting on RAFT apply callbacks per request.</param>
|
||||||
public ClusteredRequestProcessor(TimeSpan? timeout = null)
|
public ClusteredRequestProcessor(TimeSpan? timeout = null)
|
||||||
{
|
{
|
||||||
_timeout = timeout ?? DefaultTimeout;
|
_timeout = timeout ?? DefaultTimeout;
|
||||||
@@ -47,6 +51,8 @@ public sealed class ClusteredRequestProcessor
|
|||||||
/// Go reference: jetstream_cluster.go:7620 — the goroutine waits on a per-request channel
|
/// Go reference: jetstream_cluster.go:7620 — the goroutine waits on a per-request channel
|
||||||
/// with a context deadline derived from the cluster's JSApiTimeout option.
|
/// with a context deadline derived from the cluster's JSApiTimeout option.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="requestId">Correlation id returned by <see cref="RegisterPending"/>.</param>
|
||||||
|
/// <param name="ct">Cancellation token for caller-initiated cancellation.</param>
|
||||||
public async Task<JetStreamApiResponse> WaitForResultAsync(string requestId, CancellationToken ct = default)
|
public async Task<JetStreamApiResponse> WaitForResultAsync(string requestId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!_pending.TryGetValue(requestId, out var tcs))
|
if (!_pending.TryGetValue(requestId, out var tcs))
|
||||||
@@ -76,6 +82,8 @@ public sealed class ClusteredRequestProcessor
|
|||||||
/// Go reference: jetstream_cluster.go:7620 — the RAFT apply callback resolves the pending
|
/// Go reference: jetstream_cluster.go:7620 — the RAFT apply callback resolves the pending
|
||||||
/// request channel so the waiting goroutine can return the response to the caller.
|
/// request channel so the waiting goroutine can return the response to the caller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="requestId">Pending request correlation id.</param>
|
||||||
|
/// <param name="response">Response generated after RAFT proposal application.</param>
|
||||||
public bool DeliverResult(string requestId, JetStreamApiResponse response)
|
public bool DeliverResult(string requestId, JetStreamApiResponse response)
|
||||||
{
|
{
|
||||||
if (!_pending.TryRemove(requestId, out var tcs))
|
if (!_pending.TryRemove(requestId, out var tcs))
|
||||||
@@ -91,6 +99,7 @@ public sealed class ClusteredRequestProcessor
|
|||||||
/// Go reference: jetstream_cluster.go — when RAFT leadership changes, all in-flight
|
/// Go reference: jetstream_cluster.go — when RAFT leadership changes, all in-flight
|
||||||
/// proposals must be failed with a "not leader" or "cancelled" error.
|
/// proposals must be failed with a "not leader" or "cancelled" error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="reason">Reason string returned to callers in the synthesized 503 response.</param>
|
||||||
public void CancelAll(string reason = "leadership changed")
|
public void CancelAll(string reason = "leadership changed")
|
||||||
{
|
{
|
||||||
foreach (var (key, tcs) in _pending)
|
foreach (var (key, tcs) in _pending)
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ namespace NATS.Server.JetStream.Api.Handlers;
|
|||||||
|
|
||||||
public static class AccountControlApiHandlers
|
public static class AccountControlApiHandlers
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handles account-wide server-removal control requests.
|
||||||
|
/// </summary>
|
||||||
public static JetStreamApiResponse HandleServerRemove()
|
public static JetStreamApiResponse HandleServerRemove()
|
||||||
=> JetStreamApiResponse.SuccessResponse();
|
=> JetStreamApiResponse.SuccessResponse();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles account purge requests routed via API subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">API subject containing account purge target.</param>
|
||||||
public static JetStreamApiResponse HandleAccountPurge(string subject)
|
public static JetStreamApiResponse HandleAccountPurge(string subject)
|
||||||
{
|
{
|
||||||
if (!subject.StartsWith(JetStreamApiSubjects.AccountPurge, StringComparison.Ordinal))
|
if (!subject.StartsWith(JetStreamApiSubjects.AccountPurge, StringComparison.Ordinal))
|
||||||
@@ -14,6 +21,10 @@ public static class AccountControlApiHandlers
|
|||||||
return account.Length == 0 ? JetStreamApiResponse.NotFound(subject) : JetStreamApiResponse.SuccessResponse();
|
return account.Length == 0 ? JetStreamApiResponse.NotFound(subject) : JetStreamApiResponse.SuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles account stream-move requests routed via API subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">API subject containing account move target.</param>
|
||||||
public static JetStreamApiResponse HandleAccountStreamMove(string subject)
|
public static JetStreamApiResponse HandleAccountStreamMove(string subject)
|
||||||
{
|
{
|
||||||
if (!subject.StartsWith(JetStreamApiSubjects.AccountStreamMove, StringComparison.Ordinal))
|
if (!subject.StartsWith(JetStreamApiSubjects.AccountStreamMove, StringComparison.Ordinal))
|
||||||
@@ -23,6 +34,10 @@ public static class AccountControlApiHandlers
|
|||||||
return account.Length == 0 ? JetStreamApiResponse.NotFound(subject) : JetStreamApiResponse.SuccessResponse();
|
return account.Length == 0 ? JetStreamApiResponse.NotFound(subject) : JetStreamApiResponse.SuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles account stream-move cancellation requests routed via API subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">API subject containing account move-cancel target.</param>
|
||||||
public static JetStreamApiResponse HandleAccountStreamMoveCancel(string subject)
|
public static JetStreamApiResponse HandleAccountStreamMoveCancel(string subject)
|
||||||
{
|
{
|
||||||
if (!subject.StartsWith(JetStreamApiSubjects.AccountStreamMoveCancel, StringComparison.Ordinal))
|
if (!subject.StartsWith(JetStreamApiSubjects.AccountStreamMoveCancel, StringComparison.Ordinal))
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ namespace NATS.Server.JetStream.Api.Handlers;
|
|||||||
|
|
||||||
public static class ClusterControlApiHandlers
|
public static class ClusterControlApiHandlers
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handles meta-group leader stepdown requests.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meta">JetStream meta group receiving the stepdown signal.</param>
|
||||||
public static JetStreamApiResponse HandleMetaLeaderStepdown(JetStream.Cluster.JetStreamMetaGroup meta)
|
public static JetStreamApiResponse HandleMetaLeaderStepdown(JetStream.Cluster.JetStreamMetaGroup meta)
|
||||||
{
|
{
|
||||||
meta.StepDown();
|
meta.StepDown();
|
||||||
return JetStreamApiResponse.SuccessResponse();
|
return JetStreamApiResponse.SuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles stream leader stepdown requests routed via API subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">API subject containing stream leader-stepdown target.</param>
|
||||||
|
/// <param name="streams">Stream manager used to execute the stepdown action.</param>
|
||||||
public static JetStreamApiResponse HandleStreamLeaderStepdown(string subject, StreamManager streams)
|
public static JetStreamApiResponse HandleStreamLeaderStepdown(string subject, StreamManager streams)
|
||||||
{
|
{
|
||||||
if (!subject.StartsWith(JetStreamApiSubjects.StreamLeaderStepdown, StringComparison.Ordinal))
|
if (!subject.StartsWith(JetStreamApiSubjects.StreamLeaderStepdown, StringComparison.Ordinal))
|
||||||
@@ -21,6 +30,10 @@ public static class ClusterControlApiHandlers
|
|||||||
return JetStreamApiResponse.SuccessResponse();
|
return JetStreamApiResponse.SuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles stream peer removal requests routed via API subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">API subject containing stream peer-removal target.</param>
|
||||||
public static JetStreamApiResponse HandleStreamPeerRemove(string subject)
|
public static JetStreamApiResponse HandleStreamPeerRemove(string subject)
|
||||||
{
|
{
|
||||||
if (!subject.StartsWith(JetStreamApiSubjects.StreamPeerRemove, StringComparison.Ordinal))
|
if (!subject.StartsWith(JetStreamApiSubjects.StreamPeerRemove, StringComparison.Ordinal))
|
||||||
@@ -30,6 +43,10 @@ public static class ClusterControlApiHandlers
|
|||||||
return stream.Length == 0 ? JetStreamApiResponse.NotFound(subject) : JetStreamApiResponse.SuccessResponse();
|
return stream.Length == 0 ? JetStreamApiResponse.NotFound(subject) : JetStreamApiResponse.SuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles consumer leader stepdown requests routed via API subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">API subject containing stream/consumer stepdown target.</param>
|
||||||
public static JetStreamApiResponse HandleConsumerLeaderStepdown(string subject)
|
public static JetStreamApiResponse HandleConsumerLeaderStepdown(string subject)
|
||||||
{
|
{
|
||||||
if (!subject.StartsWith(JetStreamApiSubjects.ConsumerLeaderStepdown, StringComparison.Ordinal))
|
if (!subject.StartsWith(JetStreamApiSubjects.ConsumerLeaderStepdown, StringComparison.Ordinal))
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public static class AssignmentCodec
|
|||||||
/// Go reference: jetstream_cluster.go:8703 encodeAddStreamAssignment —
|
/// Go reference: jetstream_cluster.go:8703 encodeAddStreamAssignment —
|
||||||
/// marshals the assignment struct (with ConfigJSON) to JSON.
|
/// marshals the assignment struct (with ConfigJSON) to JSON.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="sa">Stream assignment payload to encode for meta-cluster replication.</param>
|
||||||
public static byte[] EncodeStreamAssignment(StreamAssignment sa)
|
public static byte[] EncodeStreamAssignment(StreamAssignment sa)
|
||||||
=> JsonSerializer.SerializeToUtf8Bytes(sa, SerializerOptions);
|
=> JsonSerializer.SerializeToUtf8Bytes(sa, SerializerOptions);
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ public static class AssignmentCodec
|
|||||||
/// Go reference: jetstream_cluster.go:8733 decodeStreamAssignment —
|
/// Go reference: jetstream_cluster.go:8733 decodeStreamAssignment —
|
||||||
/// json.Unmarshal(buf, &sa); returns nil, err on failure.
|
/// json.Unmarshal(buf, &sa); returns nil, err on failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="data">UTF-8 encoded stream assignment bytes from RAFT metadata messages.</param>
|
||||||
public static StreamAssignment? DecodeStreamAssignment(ReadOnlySpan<byte> data)
|
public static StreamAssignment? DecodeStreamAssignment(ReadOnlySpan<byte> data)
|
||||||
{
|
{
|
||||||
if (data.IsEmpty)
|
if (data.IsEmpty)
|
||||||
@@ -84,6 +86,7 @@ public static class AssignmentCodec
|
|||||||
/// Go reference: jetstream_cluster.go:9175 encodeAddConsumerAssignment —
|
/// Go reference: jetstream_cluster.go:9175 encodeAddConsumerAssignment —
|
||||||
/// marshals the assignment struct to JSON.
|
/// marshals the assignment struct to JSON.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="ca">Consumer assignment payload to encode for cluster propagation.</param>
|
||||||
public static byte[] EncodeConsumerAssignment(ConsumerAssignment ca)
|
public static byte[] EncodeConsumerAssignment(ConsumerAssignment ca)
|
||||||
=> JsonSerializer.SerializeToUtf8Bytes(ca, SerializerOptions);
|
=> JsonSerializer.SerializeToUtf8Bytes(ca, SerializerOptions);
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ public static class AssignmentCodec
|
|||||||
/// Go reference: jetstream_cluster.go:9195 decodeConsumerAssignment —
|
/// Go reference: jetstream_cluster.go:9195 decodeConsumerAssignment —
|
||||||
/// json.Unmarshal(buf, &ca); returns nil, err on failure.
|
/// json.Unmarshal(buf, &ca); returns nil, err on failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="data">UTF-8 encoded consumer assignment bytes from metadata updates.</param>
|
||||||
public static ConsumerAssignment? DecodeConsumerAssignment(ReadOnlySpan<byte> data)
|
public static ConsumerAssignment? DecodeConsumerAssignment(ReadOnlySpan<byte> data)
|
||||||
{
|
{
|
||||||
if (data.IsEmpty)
|
if (data.IsEmpty)
|
||||||
@@ -128,6 +132,8 @@ public static class AssignmentCodec
|
|||||||
/// s2.NewWriter used to compress large consumer assignment payloads; the caller
|
/// s2.NewWriter used to compress large consumer assignment payloads; the caller
|
||||||
/// prepends the assignCompressedConsumerOp opcode byte as a similar kind of marker.
|
/// prepends the assignCompressedConsumerOp opcode byte as a similar kind of marker.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="data">Uncompressed assignment payload bytes.</param>
|
||||||
|
/// <param name="threshold">Minimum byte size required before compression is applied.</param>
|
||||||
public static byte[] CompressIfLarge(byte[] data, int threshold = 1024)
|
public static byte[] CompressIfLarge(byte[] data, int threshold = 1024)
|
||||||
{
|
{
|
||||||
if (data.Length <= threshold)
|
if (data.Length <= threshold)
|
||||||
@@ -148,6 +154,7 @@ public static class AssignmentCodec
|
|||||||
/// s2.NewReader used to decompress consumer assignment payloads that were compressed
|
/// s2.NewReader used to decompress consumer assignment payloads that were compressed
|
||||||
/// before being proposed to the meta RAFT group.
|
/// before being proposed to the meta RAFT group.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="data">Compressed-or-plain payload bytes received from cluster metadata transport.</param>
|
||||||
public static byte[] DecompressIfNeeded(byte[] data)
|
public static byte[] DecompressIfNeeded(byte[] data)
|
||||||
{
|
{
|
||||||
if (data.Length > 0 && data[0] == CompressedMarker)
|
if (data.Length > 0 && data[0] == CompressedMarker)
|
||||||
|
|||||||
@@ -30,11 +30,22 @@ public sealed class JetStreamClusterMonitor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int ProcessedCount { get { lock (_processedLock) return _processedCount; } }
|
public int ProcessedCount { get { lock (_processedLock) return _processedCount; } }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a cluster monitor with a null logger for lightweight test and host setups.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meta">Meta-group state container receiving assignment updates.</param>
|
||||||
|
/// <param name="entries">RAFT entry channel consumed by the monitor loop.</param>
|
||||||
public JetStreamClusterMonitor(JetStreamMetaGroup meta, ChannelReader<RaftLogEntry> entries)
|
public JetStreamClusterMonitor(JetStreamMetaGroup meta, ChannelReader<RaftLogEntry> entries)
|
||||||
: this(meta, entries, NullLogger<JetStreamClusterMonitor>.Instance)
|
: this(meta, entries, NullLogger<JetStreamClusterMonitor>.Instance)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a cluster monitor with explicit logger injection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meta">Meta-group state container receiving assignment updates.</param>
|
||||||
|
/// <param name="entries">RAFT entry channel consumed by the monitor loop.</param>
|
||||||
|
/// <param name="logger">Logger for malformed entry and state-application diagnostics.</param>
|
||||||
public JetStreamClusterMonitor(
|
public JetStreamClusterMonitor(
|
||||||
JetStreamMetaGroup meta,
|
JetStreamMetaGroup meta,
|
||||||
ChannelReader<RaftLogEntry> entries,
|
ChannelReader<RaftLogEntry> entries,
|
||||||
@@ -50,6 +61,7 @@ public sealed class JetStreamClusterMonitor
|
|||||||
/// Each entry is applied synchronously before the next is read.
|
/// Each entry is applied synchronously before the next is read.
|
||||||
/// Returns normally (without throwing) when <paramref name="ct"/> is cancelled.
|
/// Returns normally (without throwing) when <paramref name="ct"/> is cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token used to stop the monitor loop.</param>
|
||||||
public async Task StartAsync(CancellationToken ct)
|
public async Task StartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -75,6 +87,8 @@ public sealed class JetStreamClusterMonitor
|
|||||||
/// <paramref name="targetCount"/>. Returns immediately when the target is already met.
|
/// <paramref name="targetCount"/>. Returns immediately when the target is already met.
|
||||||
/// Used by tests to synchronise without sleeping.
|
/// Used by tests to synchronise without sleeping.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="targetCount">Minimum processed-entry count to wait for.</param>
|
||||||
|
/// <param name="ct">Cancellation token for aborting the wait.</param>
|
||||||
public Task WaitForProcessedAsync(int targetCount, CancellationToken ct)
|
public Task WaitForProcessedAsync(int targetCount, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Fast path — already done.
|
// Fast path — already done.
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public static class PlacementEngine
|
|||||||
/// Overloaded peers are tried only after preferred candidates are exhausted.
|
/// Overloaded peers are tried only after preferred candidates are exhausted.
|
||||||
/// 8. Throw InvalidOperationException if fewer than replicas peers can be selected.
|
/// 8. Throw InvalidOperationException if fewer than replicas peers can be selected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="groupName">RAFT group name being placed.</param>
|
||||||
|
/// <param name="replicas">Required number of replicas/peers.</param>
|
||||||
|
/// <param name="availablePeers">Available cluster peers considered for placement.</param>
|
||||||
|
/// <param name="policy">Optional placement policy with cluster/tag constraints.</param>
|
||||||
|
/// <param name="assetCostWeight">Per-asset storage penalty used in scoring.</param>
|
||||||
public static RaftGroup SelectPeerGroup(
|
public static RaftGroup SelectPeerGroup(
|
||||||
string groupName,
|
string groupName,
|
||||||
int replicas,
|
int replicas,
|
||||||
@@ -201,10 +206,15 @@ public static class PlacementEngine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PeerInfo
|
public sealed class PeerInfo
|
||||||
{
|
{
|
||||||
|
/// <summary>Unique peer identifier used in RAFT group membership.</summary>
|
||||||
public required string PeerId { get; init; }
|
public required string PeerId { get; init; }
|
||||||
|
/// <summary>Cluster name/partition where this peer resides.</summary>
|
||||||
public string Cluster { get; set; } = string.Empty;
|
public string Cluster { get; set; } = string.Empty;
|
||||||
|
/// <summary>Capability and topology tags advertised by this peer.</summary>
|
||||||
public HashSet<string> Tags { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
public HashSet<string> Tags { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
/// <summary>Whether this peer is currently eligible for new assignments.</summary>
|
||||||
public bool Available { get; set; } = true;
|
public bool Available { get; set; } = true;
|
||||||
|
/// <summary>Approximate remaining storage available for new assets.</summary>
|
||||||
public long AvailableStorage { get; set; } = long.MaxValue;
|
public long AvailableStorage { get; set; } = long.MaxValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -228,8 +238,11 @@ public sealed class PeerInfo
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PlacementPolicy
|
public sealed class PlacementPolicy
|
||||||
{
|
{
|
||||||
|
/// <summary>Optional cluster affinity constraint.</summary>
|
||||||
public string? Cluster { get; set; }
|
public string? Cluster { get; set; }
|
||||||
|
/// <summary>Required tags that must all be present on a candidate peer.</summary>
|
||||||
public HashSet<string>? Tags { get; set; }
|
public HashSet<string>? Tags { get; set; }
|
||||||
|
/// <summary>Tags that disqualify a candidate peer when present.</summary>
|
||||||
public HashSet<string>? ExcludeTags { get; set; }
|
public HashSet<string>? ExcludeTags { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ public sealed class FilterSkipTracker
|
|||||||
private long _matchCount;
|
private long _matchCount;
|
||||||
private long _skipCount;
|
private long _skipCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a filter-skip tracker for single or multi-subject consumer filters.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filterSubject">Optional single filter subject for consumer delivery.</param>
|
||||||
|
/// <param name="filterSubjects">Optional multi-subject filter set for consumer delivery.</param>
|
||||||
public FilterSkipTracker(string? filterSubject = null, IReadOnlyList<string>? filterSubjects = null)
|
public FilterSkipTracker(string? filterSubject = null, IReadOnlyList<string>? filterSubjects = null)
|
||||||
{
|
{
|
||||||
_filterSubject = filterSubject;
|
_filterSubject = filterSubject;
|
||||||
@@ -46,6 +51,7 @@ public sealed class FilterSkipTracker
|
|||||||
/// Uses SubjectMatch.MatchLiteral for NATS token-based matching.
|
/// Uses SubjectMatch.MatchLiteral for NATS token-based matching.
|
||||||
/// Go reference: consumer.go isFilteredMatch.
|
/// Go reference: consumer.go isFilteredMatch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="subject">Message subject being evaluated for this consumer.</param>
|
||||||
public bool ShouldDeliver(string subject)
|
public bool ShouldDeliver(string subject)
|
||||||
{
|
{
|
||||||
if (!HasFilter)
|
if (!HasFilter)
|
||||||
@@ -79,6 +85,7 @@ public sealed class FilterSkipTracker
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a skipped sequence for gap tracking.
|
/// Records a skipped sequence for gap tracking.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="sequence">Stream sequence skipped due to filter mismatch.</param>
|
||||||
public void RecordSkip(ulong sequence)
|
public void RecordSkip(ulong sequence)
|
||||||
{
|
{
|
||||||
_skippedSequences.Add(sequence);
|
_skippedSequences.Add(sequence);
|
||||||
@@ -88,6 +95,7 @@ public sealed class FilterSkipTracker
|
|||||||
/// Returns the next unskipped sequence >= startSeq.
|
/// Returns the next unskipped sequence >= startSeq.
|
||||||
/// Used to find the next deliverable message efficiently.
|
/// Used to find the next deliverable message efficiently.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="startSeq">Candidate sequence to begin searching from.</param>
|
||||||
public ulong NextUnskippedSequence(ulong startSeq)
|
public ulong NextUnskippedSequence(ulong startSeq)
|
||||||
{
|
{
|
||||||
var seq = startSeq;
|
var seq = startSeq;
|
||||||
@@ -100,6 +108,7 @@ public sealed class FilterSkipTracker
|
|||||||
/// Clears skipped sequences below the given floor (e.g., ack floor).
|
/// Clears skipped sequences below the given floor (e.g., ack floor).
|
||||||
/// Prevents unbounded growth.
|
/// Prevents unbounded growth.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="floor">Inclusive lower bound; skipped sequences below this value are removed.</param>
|
||||||
public void PurgeBelow(ulong floor)
|
public void PurgeBelow(ulong floor)
|
||||||
{
|
{
|
||||||
_skippedSequences.RemoveWhere(s => s < floor);
|
_skippedSequences.RemoveWhere(s => s < floor);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class SampleTracker
|
|||||||
/// Creates a sample tracker with the given rate (0.0 to 1.0).
|
/// Creates a sample tracker with the given rate (0.0 to 1.0).
|
||||||
/// Use ParseSampleFrequency to convert string like "1%" to rate.
|
/// Use ParseSampleFrequency to convert string like "1%" to rate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="sampleRate">Sampling probability as a fraction from 0.0 to 1.0.</param>
|
||||||
|
/// <param name="random">Optional random source for deterministic testability.</param>
|
||||||
public SampleTracker(double sampleRate, Random? random = null)
|
public SampleTracker(double sampleRate, Random? random = null)
|
||||||
{
|
{
|
||||||
_sampleRate = Math.Clamp(sampleRate, 0.0, 1.0);
|
_sampleRate = Math.Clamp(sampleRate, 0.0, 1.0);
|
||||||
@@ -61,6 +63,9 @@ public sealed class SampleTracker
|
|||||||
/// Records a latency measurement for a sampled delivery.
|
/// Records a latency measurement for a sampled delivery.
|
||||||
/// Returns a LatencySample for advisory publication.
|
/// Returns a LatencySample for advisory publication.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="deliveryLatency">Observed delivery latency for the sampled message.</param>
|
||||||
|
/// <param name="sequence">Stream sequence number of the sampled message.</param>
|
||||||
|
/// <param name="subject">Subject delivered to the consumer.</param>
|
||||||
public LatencySample RecordLatency(TimeSpan deliveryLatency, ulong sequence, string subject)
|
public LatencySample RecordLatency(TimeSpan deliveryLatency, ulong sequence, string subject)
|
||||||
{
|
{
|
||||||
return new LatencySample
|
return new LatencySample
|
||||||
@@ -78,6 +83,7 @@ public sealed class SampleTracker
|
|||||||
/// Returns 0.0 for invalid or empty strings.
|
/// Returns 0.0 for invalid or empty strings.
|
||||||
/// Go reference: consumer.go parseSampleFrequency.
|
/// Go reference: consumer.go parseSampleFrequency.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="frequency">Human-readable percentage string.</param>
|
||||||
public static double ParseSampleFrequency(string? frequency)
|
public static double ParseSampleFrequency(string? frequency)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(frequency))
|
if (string.IsNullOrWhiteSpace(frequency))
|
||||||
@@ -104,8 +110,12 @@ public sealed class SampleTracker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LatencySample
|
public sealed class LatencySample
|
||||||
{
|
{
|
||||||
|
/// <summary>Stream sequence number of the sampled message.</summary>
|
||||||
public ulong Sequence { get; init; }
|
public ulong Sequence { get; init; }
|
||||||
|
/// <summary>Subject delivered to the consumer.</summary>
|
||||||
public string Subject { get; init; } = string.Empty;
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
/// <summary>Observed delivery latency for this sample.</summary>
|
||||||
public TimeSpan DeliveryLatency { get; init; }
|
public TimeSpan DeliveryLatency { get; init; }
|
||||||
|
/// <summary>UTC timestamp when the sample was captured.</summary>
|
||||||
public DateTime SampledAtUtc { get; init; }
|
public DateTime SampledAtUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public sealed class TokenBucketRateLimiter
|
|||||||
/// Returns true if tokens were available (message can be sent).
|
/// Returns true if tokens were available (message can be sent).
|
||||||
/// Returns false if not enough tokens (caller should wait).
|
/// Returns false if not enough tokens (caller should wait).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="bytes">Number of payload bytes to reserve from the token bucket.</param>
|
||||||
public bool TryConsume(long bytes)
|
public bool TryConsume(long bytes)
|
||||||
{
|
{
|
||||||
if (BytesPerSecond <= 0) return true; // Unlimited
|
if (BytesPerSecond <= 0) return true; // Unlimited
|
||||||
@@ -69,6 +70,7 @@ public sealed class TokenBucketRateLimiter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the estimated wait time until enough tokens are available.
|
/// Returns the estimated wait time until enough tokens are available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="bytes">Number of bytes needed before send can proceed.</param>
|
||||||
public TimeSpan EstimateWait(long bytes)
|
public TimeSpan EstimateWait(long bytes)
|
||||||
{
|
{
|
||||||
if (BytesPerSecond <= 0) return TimeSpan.Zero;
|
if (BytesPerSecond <= 0) return TimeSpan.Zero;
|
||||||
@@ -87,6 +89,8 @@ public sealed class TokenBucketRateLimiter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Waits until enough tokens are available, then consumes them.
|
/// Waits until enough tokens are available, then consumes them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="bytes">Number of payload bytes that must be available.</param>
|
||||||
|
/// <param name="ct">Cancellation token to stop waiting when request processing is canceled.</param>
|
||||||
public async ValueTask WaitForTokensAsync(long bytes, CancellationToken ct = default)
|
public async ValueTask WaitForTokensAsync(long bytes, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (BytesPerSecond <= 0) return;
|
if (BytesPerSecond <= 0) return;
|
||||||
@@ -107,6 +111,8 @@ public sealed class TokenBucketRateLimiter
|
|||||||
/// Updates the rate dynamically.
|
/// Updates the rate dynamically.
|
||||||
/// Go reference: consumer.go — rate can change on config update.
|
/// Go reference: consumer.go — rate can change on config update.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="bytesPerSecond">New throughput limit in bytes per second.</param>
|
||||||
|
/// <param name="burstSize">Optional new burst ceiling; defaults to two seconds of throughput.</param>
|
||||||
public void UpdateRate(long bytesPerSecond, long burstSize = 0)
|
public void UpdateRate(long bytesPerSecond, long burstSize = 0)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed record PullRequest(
|
|||||||
public void ConsumeBatch() => RemainingBatch--;
|
public void ConsumeBatch() => RemainingBatch--;
|
||||||
|
|
||||||
/// <summary>Subtract delivered bytes from remaining byte budget.</summary>
|
/// <summary>Subtract delivered bytes from remaining byte budget.</summary>
|
||||||
|
/// <param name="bytes">Delivered payload bytes to subtract from this pull request budget.</param>
|
||||||
public void ConsumeBytes(long bytes) => RemainingBytes -= bytes;
|
public void ConsumeBytes(long bytes) => RemainingBytes -= bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +39,25 @@ public sealed class WaitingRequestQueue
|
|||||||
{
|
{
|
||||||
private readonly LinkedList<PullRequest> _queue = new();
|
private readonly LinkedList<PullRequest> _queue = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current number of queued pull requests awaiting delivery.
|
||||||
|
/// </summary>
|
||||||
public int Count => _queue.Count;
|
public int Count => _queue.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether there are no pending pull requests.
|
||||||
|
/// </summary>
|
||||||
public bool IsEmpty => _queue.Count == 0;
|
public bool IsEmpty => _queue.Count == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a pull request at the tail of the FIFO wait queue.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Pull request to add.</param>
|
||||||
public void Enqueue(PullRequest request) => _queue.AddLast(request);
|
public void Enqueue(PullRequest request) => _queue.AddLast(request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dequeues the oldest pending pull request, or <see langword="null"/> when empty.
|
||||||
|
/// </summary>
|
||||||
public PullRequest? TryDequeue()
|
public PullRequest? TryDequeue()
|
||||||
{
|
{
|
||||||
if (_queue.Count == 0) return null;
|
if (_queue.Count == 0) return null;
|
||||||
@@ -51,6 +66,10 @@ public sealed class WaitingRequestQueue
|
|||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes expired pull requests whose deadline is at or before <paramref name="now"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="now">Current timestamp used for expiry comparison.</param>
|
||||||
public void RemoveExpired(DateTimeOffset now)
|
public void RemoveExpired(DateTimeOffset now)
|
||||||
{
|
{
|
||||||
var node = _queue.First;
|
var node = _queue.First;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public sealed class InterestRetentionPolicy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Register a consumer's interest in a subject pattern.
|
/// Register a consumer's interest in a subject pattern.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="consumer">Durable or ephemeral consumer name whose interest is tracked.</param>
|
||||||
|
/// <param name="filterSubject">Consumer filter subject used to determine message relevance.</param>
|
||||||
public void RegisterInterest(string consumer, string filterSubject)
|
public void RegisterInterest(string consumer, string filterSubject)
|
||||||
{
|
{
|
||||||
_interests[consumer] = filterSubject;
|
_interests[consumer] = filterSubject;
|
||||||
@@ -26,6 +28,7 @@ public sealed class InterestRetentionPolicy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remove a consumer's interest (e.g., on deletion).
|
/// Remove a consumer's interest (e.g., on deletion).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="consumer">Consumer identifier to remove from interest tracking.</param>
|
||||||
public void UnregisterInterest(string consumer)
|
public void UnregisterInterest(string consumer)
|
||||||
{
|
{
|
||||||
_interests.Remove(consumer);
|
_interests.Remove(consumer);
|
||||||
@@ -34,6 +37,8 @@ public sealed class InterestRetentionPolicy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Record that a consumer has acknowledged delivery of a sequence.
|
/// Record that a consumer has acknowledged delivery of a sequence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="consumer">Consumer that acknowledged delivery.</param>
|
||||||
|
/// <param name="seq">Stream sequence number being acknowledged.</param>
|
||||||
public void AcknowledgeDelivery(string consumer, ulong seq)
|
public void AcknowledgeDelivery(string consumer, ulong seq)
|
||||||
{
|
{
|
||||||
if (!_acks.TryGetValue(seq, out var ackedBy))
|
if (!_acks.TryGetValue(seq, out var ackedBy))
|
||||||
@@ -49,6 +54,8 @@ public sealed class InterestRetentionPolicy
|
|||||||
/// interested consumer has NOT yet acknowledged it).
|
/// interested consumer has NOT yet acknowledged it).
|
||||||
/// A consumer is "interested" if its filter subject matches the message subject.
|
/// A consumer is "interested" if its filter subject matches the message subject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="seq">Stream sequence being evaluated for retention eligibility.</param>
|
||||||
|
/// <param name="msgSubject">Published subject of the message at <paramref name="seq"/>.</param>
|
||||||
public bool ShouldRetain(ulong seq, string msgSubject)
|
public bool ShouldRetain(ulong seq, string msgSubject)
|
||||||
{
|
{
|
||||||
_acks.TryGetValue(seq, out var ackedBy);
|
_acks.TryGetValue(seq, out var ackedBy);
|
||||||
|
|||||||
@@ -19,16 +19,36 @@ public static class JetStreamProfiler
|
|||||||
private static long _ackDeliverTicks;
|
private static long _ackDeliverTicks;
|
||||||
private static long _totalProcessMessageTicks;
|
private static long _totalProcessMessageTicks;
|
||||||
|
|
||||||
|
/// <summary>Records ticks spent in stream subject-to-stream resolution.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordFindBySubject(long ticks) => Interlocked.Add(ref _findBySubjectTicks, ticks);
|
public static void RecordFindBySubject(long ticks) => Interlocked.Add(ref _findBySubjectTicks, ticks);
|
||||||
|
/// <summary>Records ticks spent loading stream state metadata.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordGetState(long ticks) => Interlocked.Add(ref _getStateTicks, ticks);
|
public static void RecordGetState(long ticks) => Interlocked.Add(ref _getStateTicks, ticks);
|
||||||
|
/// <summary>Records ticks spent appending to stream storage.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordAppend(long ticks) => Interlocked.Add(ref _appendTicks, ticks);
|
public static void RecordAppend(long ticks) => Interlocked.Add(ref _appendTicks, ticks);
|
||||||
|
/// <summary>Records ticks spent enforcing stream retention/limit policies.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordEnforcePolicies(long ticks) => Interlocked.Add(ref _enforcePoliciesTicks, ticks);
|
public static void RecordEnforcePolicies(long ticks) => Interlocked.Add(ref _enforcePoliciesTicks, ticks);
|
||||||
|
/// <summary>Records residual capture overhead not attributed to named stages.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordCaptureOverhead(long ticks) => Interlocked.Add(ref _captureOverheadTicks, ticks);
|
public static void RecordCaptureOverhead(long ticks) => Interlocked.Add(ref _captureOverheadTicks, ticks);
|
||||||
|
/// <summary>Records ticks spent serializing publish-related JSON responses/events.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordJsonSerialize(long ticks) => Interlocked.Add(ref _jsonSerializeTicks, ticks);
|
public static void RecordJsonSerialize(long ticks) => Interlocked.Add(ref _jsonSerializeTicks, ticks);
|
||||||
|
/// <summary>Records ticks spent in ack-delivery and ack-path bookkeeping.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordAckDeliver(long ticks) => Interlocked.Add(ref _ackDeliverTicks, ticks);
|
public static void RecordAckDeliver(long ticks) => Interlocked.Add(ref _ackDeliverTicks, ticks);
|
||||||
|
/// <summary>Records total ticks spent processing a message end-to-end.</summary>
|
||||||
|
/// <param name="ticks">Elapsed stopwatch ticks.</param>
|
||||||
public static void RecordTotalProcessMessage(long ticks) => Interlocked.Add(ref _totalProcessMessageTicks, ticks);
|
public static void RecordTotalProcessMessage(long ticks) => Interlocked.Add(ref _totalProcessMessageTicks, ticks);
|
||||||
|
/// <summary>Increments the total processed-call counter.</summary>
|
||||||
public static void IncrementCalls() => Interlocked.Increment(ref _totalCalls);
|
public static void IncrementCalls() => Interlocked.Increment(ref _totalCalls);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a formatted profile report and resets all accumulated counters.
|
||||||
|
/// </summary>
|
||||||
public static string DumpAndReset()
|
public static string DumpAndReset()
|
||||||
{
|
{
|
||||||
var calls = Interlocked.Exchange(ref _totalCalls, 0);
|
var calls = Interlocked.Exchange(ref _totalCalls, 0);
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ public sealed class JetStreamService : IAsyncDisposable
|
|||||||
private readonly ILogger<JetStreamService> _logger;
|
private readonly ILogger<JetStreamService> _logger;
|
||||||
private List<string> _registeredApiSubjects = [];
|
private List<string> _registeredApiSubjects = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the internal client used for local system publications, when configured.
|
||||||
|
/// </summary>
|
||||||
public InternalClient? InternalClient { get; }
|
public InternalClient? InternalClient { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether JetStream API subjects are currently registered.
|
||||||
|
/// </summary>
|
||||||
public bool IsRunning { get; private set; }
|
public bool IsRunning { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -77,11 +84,22 @@ public sealed class JetStreamService : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long MaxStore => _options.MaxFileStore;
|
public long MaxStore => _options.MaxFileStore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a JetStream service using null logging for lightweight host wiring.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">JetStream configuration limits and storage settings.</param>
|
||||||
|
/// <param name="internalClient">Optional internal client for server-generated JetStream API traffic.</param>
|
||||||
public JetStreamService(JetStreamOptions options, InternalClient? internalClient = null)
|
public JetStreamService(JetStreamOptions options, InternalClient? internalClient = null)
|
||||||
: this(options, internalClient, NullLoggerFactory.Instance)
|
: this(options, internalClient, NullLoggerFactory.Instance)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a JetStream service with explicit logging factory control.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">JetStream configuration limits and storage settings.</param>
|
||||||
|
/// <param name="internalClient">Optional internal client for server-generated JetStream API traffic.</param>
|
||||||
|
/// <param name="loggerFactory">Logger factory used to create component loggers.</param>
|
||||||
public JetStreamService(JetStreamOptions options, InternalClient? internalClient, ILoggerFactory loggerFactory)
|
public JetStreamService(JetStreamOptions options, InternalClient? internalClient, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
@@ -92,6 +110,10 @@ public sealed class JetStreamService : IAsyncDisposable
|
|||||||
// Maps to Go's enableJetStream() in server/jetstream.go:414-523.
|
// Maps to Go's enableJetStream() in server/jetstream.go:414-523.
|
||||||
// Validates the store directory, creates it if absent, then registers all
|
// Validates the store directory, creates it if absent, then registers all
|
||||||
// $JS.API.> subjects so inbound API messages can be routed.
|
// $JS.API.> subjects so inbound API messages can be routed.
|
||||||
|
/// <summary>
|
||||||
|
/// Starts JetStream by validating storage and registering all JetStream API subjects.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token for startup flow control.</param>
|
||||||
public Task StartAsync(CancellationToken ct)
|
public Task StartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (IsRunning)
|
if (IsRunning)
|
||||||
@@ -138,6 +160,9 @@ public sealed class JetStreamService : IAsyncDisposable
|
|||||||
|
|
||||||
// Maps to Go's shutdown path in jetstream.go.
|
// Maps to Go's shutdown path in jetstream.go.
|
||||||
// Clears registered subjects and marks the service as not running.
|
// Clears registered subjects and marks the service as not running.
|
||||||
|
/// <summary>
|
||||||
|
/// Stops JetStream and removes registered API subjects.
|
||||||
|
/// </summary>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
_registeredApiSubjects = [];
|
_registeredApiSubjects = [];
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public static class JsVersioning
|
|||||||
/// Returns the required API level string from metadata, or empty if absent.
|
/// Returns the required API level string from metadata, or empty if absent.
|
||||||
/// Go: getRequiredApiLevel (jetstream_versioning.go:28)
|
/// Go: getRequiredApiLevel (jetstream_versioning.go:28)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="metadata">Metadata dictionary that may contain the required-level key.</param>
|
||||||
public static string GetRequiredApiLevel(Dictionary<string, string>? metadata)
|
public static string GetRequiredApiLevel(Dictionary<string, string>? metadata)
|
||||||
{
|
{
|
||||||
if (metadata != null && metadata.TryGetValue(RequiredLevelKey, out var level) && level.Length > 0)
|
if (metadata != null && metadata.TryGetValue(RequiredLevelKey, out var level) && level.Length > 0)
|
||||||
@@ -45,6 +46,7 @@ public static class JsVersioning
|
|||||||
/// Returns whether the required API level is supported by this server.
|
/// Returns whether the required API level is supported by this server.
|
||||||
/// Go: supportsRequiredApiLevel (jetstream_versioning.go:36)
|
/// Go: supportsRequiredApiLevel (jetstream_versioning.go:36)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="metadata">Metadata dictionary that may contain required-level information.</param>
|
||||||
public static bool SupportsRequiredApiLevel(Dictionary<string, string>? metadata)
|
public static bool SupportsRequiredApiLevel(Dictionary<string, string>? metadata)
|
||||||
{
|
{
|
||||||
var level = GetRequiredApiLevel(metadata);
|
var level = GetRequiredApiLevel(metadata);
|
||||||
@@ -60,6 +62,7 @@ public static class JsVersioning
|
|||||||
/// Clears dynamic fields (server version/level) and sets the required API level.
|
/// Clears dynamic fields (server version/level) and sets the required API level.
|
||||||
/// Go: setStaticStreamMetadata (jetstream_versioning.go:44)
|
/// Go: setStaticStreamMetadata (jetstream_versioning.go:44)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="cfg">Stream configuration to mutate with static version metadata.</param>
|
||||||
public static void SetStaticStreamMetadata(StreamConfig cfg)
|
public static void SetStaticStreamMetadata(StreamConfig cfg)
|
||||||
{
|
{
|
||||||
if (cfg.Metadata == null)
|
if (cfg.Metadata == null)
|
||||||
@@ -98,6 +101,7 @@ public static class JsVersioning
|
|||||||
/// The original config is not modified.
|
/// The original config is not modified.
|
||||||
/// Go: setDynamicStreamMetadata (jetstream_versioning.go:88)
|
/// Go: setDynamicStreamMetadata (jetstream_versioning.go:88)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="cfg">Source stream configuration.</param>
|
||||||
public static StreamConfig SetDynamicStreamMetadata(StreamConfig cfg)
|
public static StreamConfig SetDynamicStreamMetadata(StreamConfig cfg)
|
||||||
{
|
{
|
||||||
// Shallow copy the config
|
// Shallow copy the config
|
||||||
@@ -118,6 +122,8 @@ public static class JsVersioning
|
|||||||
/// Removes dynamic fields. If prevCfg has no metadata, removes the key from cfg.
|
/// Removes dynamic fields. If prevCfg has no metadata, removes the key from cfg.
|
||||||
/// Go: copyStreamMetadata (jetstream_versioning.go:110)
|
/// Go: copyStreamMetadata (jetstream_versioning.go:110)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="cfg">Target stream configuration to update.</param>
|
||||||
|
/// <param name="prevCfg">Previous stream configuration whose required-level metadata is preserved.</param>
|
||||||
public static void CopyStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg)
|
public static void CopyStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg)
|
||||||
{
|
{
|
||||||
if (cfg.Metadata != null)
|
if (cfg.Metadata != null)
|
||||||
@@ -129,6 +135,7 @@ public static class JsVersioning
|
|||||||
/// Sets static (stored) versioning metadata on a consumer config.
|
/// Sets static (stored) versioning metadata on a consumer config.
|
||||||
/// Go: setStaticConsumerMetadata (jetstream_versioning.go:136)
|
/// Go: setStaticConsumerMetadata (jetstream_versioning.go:136)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="cfg">Consumer configuration to mutate with static version metadata.</param>
|
||||||
public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
|
public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
|
||||||
{
|
{
|
||||||
if (cfg.Metadata == null)
|
if (cfg.Metadata == null)
|
||||||
@@ -154,6 +161,7 @@ public static class JsVersioning
|
|||||||
/// Returns a copy of the consumer config with dynamic metadata fields added.
|
/// Returns a copy of the consumer config with dynamic metadata fields added.
|
||||||
/// Go: setDynamicConsumerMetadata (jetstream_versioning.go:164)
|
/// Go: setDynamicConsumerMetadata (jetstream_versioning.go:164)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="cfg">Source consumer configuration.</param>
|
||||||
public static ConsumerConfig SetDynamicConsumerMetadata(ConsumerConfig cfg)
|
public static ConsumerConfig SetDynamicConsumerMetadata(ConsumerConfig cfg)
|
||||||
{
|
{
|
||||||
var newCfg = ShallowCopyConsumer(cfg);
|
var newCfg = ShallowCopyConsumer(cfg);
|
||||||
@@ -173,6 +181,8 @@ public static class JsVersioning
|
|||||||
/// Removes dynamic fields.
|
/// Removes dynamic fields.
|
||||||
/// Go: copyConsumerMetadata (jetstream_versioning.go:198)
|
/// Go: copyConsumerMetadata (jetstream_versioning.go:198)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="cfg">Target consumer configuration to update.</param>
|
||||||
|
/// <param name="prevCfg">Previous consumer configuration whose required-level metadata is preserved.</param>
|
||||||
public static void CopyConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg)
|
public static void CopyConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg)
|
||||||
{
|
{
|
||||||
if (cfg.Metadata != null)
|
if (cfg.Metadata != null)
|
||||||
@@ -184,6 +194,7 @@ public static class JsVersioning
|
|||||||
/// Removes dynamic metadata fields (server version and level) from a metadata dictionary.
|
/// Removes dynamic metadata fields (server version and level) from a metadata dictionary.
|
||||||
/// Go: deleteDynamicMetadata (jetstream_versioning.go:222)
|
/// Go: deleteDynamicMetadata (jetstream_versioning.go:222)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="metadata">Metadata dictionary to clean.</param>
|
||||||
public static void DeleteDynamicMetadata(Dictionary<string, string> metadata)
|
public static void DeleteDynamicMetadata(Dictionary<string, string> metadata)
|
||||||
{
|
{
|
||||||
metadata.Remove(ServerVersionKey);
|
metadata.Remove(ServerVersionKey);
|
||||||
|
|||||||
@@ -7,15 +7,32 @@ namespace NATS.Server.JetStream.Models;
|
|||||||
// Reference: golang/nats-server/server/stream.go:20759
|
// Reference: golang/nats-server/server/stream.go:20759
|
||||||
public sealed class CounterValue
|
public sealed class CounterValue
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the counter value encoded as a string for wire compatibility.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("val")]
|
[JsonPropertyName("val")]
|
||||||
public string Value { get; set; } = "0";
|
public string Value { get; set; } = "0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the string counter value as a signed 64-bit integer.
|
||||||
|
/// </summary>
|
||||||
public long AsLong() => long.TryParse(Value, out var v) ? v : 0;
|
public long AsLong() => long.TryParse(Value, out var v) ? v : 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a counter payload object from a numeric value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">Numeric counter value to encode.</param>
|
||||||
public static CounterValue FromLong(long value) => new() { Value = value.ToString() };
|
public static CounterValue FromLong(long value) => new() { Value = value.ToString() };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the counter payload to JSON UTF-8 bytes.
|
||||||
|
/// </summary>
|
||||||
public byte[] ToPayload() => JsonSerializer.SerializeToUtf8Bytes(this);
|
public byte[] ToPayload() => JsonSerializer.SerializeToUtf8Bytes(this);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes a counter payload from JSON bytes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="payload">JSON payload bytes containing the counter value.</param>
|
||||||
public static CounterValue FromPayload(ReadOnlySpan<byte> payload)
|
public static CounterValue FromPayload(ReadOnlySpan<byte> payload)
|
||||||
{
|
{
|
||||||
if (payload.IsEmpty)
|
if (payload.IsEmpty)
|
||||||
|
|||||||
@@ -2,8 +2,23 @@ namespace NATS.Server.JetStream.Models;
|
|||||||
|
|
||||||
public sealed class ApiStreamState
|
public sealed class ApiStreamState
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets current number of messages stored in the stream.
|
||||||
|
/// </summary>
|
||||||
public ulong Messages { get; set; }
|
public ulong Messages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets first available stream sequence.
|
||||||
|
/// </summary>
|
||||||
public ulong FirstSeq { get; set; }
|
public ulong FirstSeq { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets latest stream sequence.
|
||||||
|
/// </summary>
|
||||||
public ulong LastSeq { get; set; }
|
public ulong LastSeq { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets total bytes retained by the stream.
|
||||||
|
/// </summary>
|
||||||
public ulong Bytes { get; set; }
|
public ulong Bytes { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ internal static class JetStreamPubAckFormatter
|
|||||||
/// Formats a success PubAck directly into a span. Returns bytes written.
|
/// Formats a success PubAck directly into a span. Returns bytes written.
|
||||||
/// Caller must ensure dest is large enough (256 bytes is safe for any stream name).
|
/// Caller must ensure dest is large enough (256 bytes is safe for any stream name).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="dest">Destination span receiving UTF-8 formatted JSON bytes.</param>
|
||||||
|
/// <param name="streamName">Stream name included in the ack payload.</param>
|
||||||
|
/// <param name="seq">Assigned stream sequence to include in the ack payload.</param>
|
||||||
public static int FormatSuccess(Span<byte> dest, string streamName, ulong seq)
|
public static int FormatSuccess(Span<byte> dest, string streamName, ulong seq)
|
||||||
{
|
{
|
||||||
var pos = 0;
|
var pos = 0;
|
||||||
@@ -36,6 +39,7 @@ internal static class JetStreamPubAckFormatter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if this PubAck is a simple success that can use the fast formatter.
|
/// Returns true if this PubAck is a simple success that can use the fast formatter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="ack">Publish acknowledgement to classify.</param>
|
||||||
public static bool IsSimpleSuccess(PubAck ack)
|
public static bool IsSimpleSuccess(PubAck ack)
|
||||||
=> ack.ErrorCode == null && !ack.Duplicate && ack.BatchId == null;
|
=> ack.ErrorCode == null && !ack.Duplicate && ack.BatchId == null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,41 @@ public sealed class JetStreamPublisher
|
|||||||
// Go reference: server/jetstream_batching.go streamBatches
|
// Go reference: server/jetstream_batching.go streamBatches
|
||||||
private readonly AtomicBatchPublishEngine _batchEngine = new();
|
private readonly AtomicBatchPublishEngine _batchEngine = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a JetStream publisher bound to a stream manager.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="streamManager">Stream manager used to resolve and capture published messages.</param>
|
||||||
public JetStreamPublisher(StreamManager streamManager)
|
public JetStreamPublisher(StreamManager streamManager)
|
||||||
{
|
{
|
||||||
_streamManager = streamManager;
|
_streamManager = streamManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures a publish using default publish options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Publish subject.</param>
|
||||||
|
/// <param name="payload">Message payload.</param>
|
||||||
|
/// <param name="ack">Publish acknowledgement output.</param>
|
||||||
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, out PubAck ack)
|
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, out PubAck ack)
|
||||||
=> TryCaptureWithOptions(subject, payload, new PublishOptions(), out ack);
|
=> TryCaptureWithOptions(subject, payload, new PublishOptions(), out ack);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures a publish with an explicit message id for deduplication checks.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Publish subject.</param>
|
||||||
|
/// <param name="payload">Message payload.</param>
|
||||||
|
/// <param name="msgId">Optional message id used for duplicate detection.</param>
|
||||||
|
/// <param name="ack">Publish acknowledgement output.</param>
|
||||||
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, string? msgId, out PubAck ack)
|
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, string? msgId, out PubAck ack)
|
||||||
=> TryCaptureWithOptions(subject, payload, new PublishOptions { MsgId = msgId }, out ack);
|
=> TryCaptureWithOptions(subject, payload, new PublishOptions { MsgId = msgId }, out ack);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures a publish using explicit publish options and precondition checks.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Publish subject.</param>
|
||||||
|
/// <param name="payload">Message payload.</param>
|
||||||
|
/// <param name="options">Publish options including dedupe and expected-last preconditions.</param>
|
||||||
|
/// <param name="ack">Publish acknowledgement output.</param>
|
||||||
public bool TryCaptureWithOptions(string subject, ReadOnlyMemory<byte> payload, PublishOptions options, out PubAck ack)
|
public bool TryCaptureWithOptions(string subject, ReadOnlyMemory<byte> payload, PublishOptions options, out PubAck ack)
|
||||||
{
|
{
|
||||||
if (_streamManager.FindBySubject(subject) is not { } stream)
|
if (_streamManager.FindBySubject(subject) is not { } stream)
|
||||||
|
|||||||
@@ -2,16 +2,37 @@ namespace NATS.Server.JetStream.Publish;
|
|||||||
|
|
||||||
public sealed class PubAck
|
public sealed class PubAck
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the stream name that accepted the published message.
|
||||||
|
/// </summary>
|
||||||
public string Stream { get; init; } = string.Empty;
|
public string Stream { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the stream sequence assigned to the accepted message.
|
||||||
|
/// </summary>
|
||||||
public ulong Seq { get; init; }
|
public ulong Seq { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether this acknowledgement represents a deduplicated publish.
|
||||||
|
/// </summary>
|
||||||
public bool Duplicate { get; init; }
|
public bool Duplicate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the JetStream API error code when the publish was rejected.
|
||||||
|
/// </summary>
|
||||||
public int? ErrorCode { get; init; }
|
public int? ErrorCode { get; init; }
|
||||||
|
|
||||||
// Go: JSPubAckResponse.BatchId — identifies which batch this ack belongs to.
|
// Go: JSPubAckResponse.BatchId — identifies which batch this ack belongs to.
|
||||||
// Go reference: server/jetstream_batching.go (JSPubAckResponse struct)
|
// Go reference: server/jetstream_batching.go (JSPubAckResponse struct)
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the batch identifier when this ack belongs to an atomic publish batch.
|
||||||
|
/// </summary>
|
||||||
public string? BatchId { get; init; }
|
public string? BatchId { get; init; }
|
||||||
|
|
||||||
// Go: JSPubAckResponse.BatchSize — total number of messages committed in this batch.
|
// Go: JSPubAckResponse.BatchSize — total number of messages committed in this batch.
|
||||||
// Go reference: server/jetstream_batching.go (JSPubAckResponse struct)
|
// Go reference: server/jetstream_batching.go (JSPubAckResponse struct)
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of messages committed in the acknowledged batch.
|
||||||
|
/// </summary>
|
||||||
public int BatchSize { get; init; }
|
public int BatchSize { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,47 @@ namespace NATS.Server.JetStream.Publish;
|
|||||||
|
|
||||||
public sealed class PublishOptions
|
public sealed class PublishOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the idempotency token used to deduplicate retried publishes on the stream.
|
||||||
|
/// </summary>
|
||||||
public string? MsgId { get; init; }
|
public string? MsgId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the expected stream last sequence precondition for optimistic concurrency checks.
|
||||||
|
/// </summary>
|
||||||
public ulong ExpectedLastSeq { get; init; }
|
public ulong ExpectedLastSeq { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the expected last sequence for a specific subject when enforcing subject-level ordering.
|
||||||
|
/// </summary>
|
||||||
public ulong ExpectedLastSubjectSeq { get; init; }
|
public ulong ExpectedLastSubjectSeq { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the subject associated with <see cref="ExpectedLastSubjectSeq"/> precondition checks.
|
||||||
|
/// </summary>
|
||||||
public string? ExpectedLastSubjectSeqSubject { get; init; }
|
public string? ExpectedLastSubjectSeqSubject { get; init; }
|
||||||
|
|
||||||
// Go: Nats-Batch-Id header — identifies which atomic batch this message belongs to.
|
// Go: Nats-Batch-Id header — identifies which atomic batch this message belongs to.
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the batch identifier used to group staged messages into a single commit set.
|
||||||
|
/// </summary>
|
||||||
public string? BatchId { get; init; }
|
public string? BatchId { get; init; }
|
||||||
|
|
||||||
// Go: Nats-Batch-Sequence header — 1-based position within the batch.
|
// Go: Nats-Batch-Sequence header — 1-based position within the batch.
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the 1-based position of this message within its JetStream publish batch.
|
||||||
|
/// </summary>
|
||||||
public ulong BatchSeq { get; init; }
|
public ulong BatchSeq { get; init; }
|
||||||
|
|
||||||
// Go: Nats-Batch-Commit header — "1" or "eob" to commit, null/empty to stage only.
|
// Go: Nats-Batch-Commit header — "1" or "eob" to commit, null/empty to stage only.
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the batch commit marker signaling end-of-batch or explicit commit behavior.
|
||||||
|
/// </summary>
|
||||||
public string? BatchCommit { get; init; }
|
public string? BatchCommit { get; init; }
|
||||||
|
|
||||||
// Go: Nats-Expected-Last-Msg-Id header — unsupported inside a batch.
|
// Go: Nats-Expected-Last-Msg-Id header — unsupported inside a batch.
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the expected last message id precondition used to guard against duplicate writes.
|
||||||
|
/// </summary>
|
||||||
public string? ExpectedLastMsgId { get; init; }
|
public string? ExpectedLastMsgId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ public sealed class PublishPreconditions
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, DedupeEntry> _dedupe = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, DedupeEntry> _dedupe = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a message id is still inside the duplicate window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="msgId">Message-id header used for deduplication.</param>
|
||||||
|
/// <param name="duplicateWindowMs">Duplicate window size in milliseconds.</param>
|
||||||
|
/// <param name="existingSequence">Existing stored sequence when a duplicate is detected.</param>
|
||||||
public bool IsDuplicate(string? msgId, int duplicateWindowMs, out ulong existingSequence)
|
public bool IsDuplicate(string? msgId, int duplicateWindowMs, out ulong existingSequence)
|
||||||
{
|
{
|
||||||
existingSequence = 0;
|
existingSequence = 0;
|
||||||
@@ -26,6 +32,11 @@ public sealed class PublishPreconditions
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a message id and sequence for future duplicate detection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="msgId">Message-id header value to track.</param>
|
||||||
|
/// <param name="sequence">Stream sequence assigned to the message.</param>
|
||||||
public void Record(string? msgId, ulong sequence)
|
public void Record(string? msgId, ulong sequence)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(msgId))
|
if (string.IsNullOrEmpty(msgId))
|
||||||
@@ -34,6 +45,10 @@ public sealed class PublishPreconditions
|
|||||||
_dedupe[msgId] = new DedupeEntry(sequence, DateTime.UtcNow);
|
_dedupe[msgId] = new DedupeEntry(sequence, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes dedupe entries older than the duplicate window.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="duplicateWindowMs">Duplicate window size in milliseconds.</param>
|
||||||
public void TrimOlderThan(int duplicateWindowMs)
|
public void TrimOlderThan(int duplicateWindowMs)
|
||||||
{
|
{
|
||||||
if (duplicateWindowMs <= 0)
|
if (duplicateWindowMs <= 0)
|
||||||
@@ -47,6 +62,11 @@ public sealed class PublishPreconditions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates expected-last-sequence precondition against current stream sequence.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="expectedLastSeq">Expected last sequence from publish precondition header.</param>
|
||||||
|
/// <param name="actualLastSeq">Current stream last sequence.</param>
|
||||||
public bool CheckExpectedLastSeq(ulong expectedLastSeq, ulong actualLastSeq)
|
public bool CheckExpectedLastSeq(ulong expectedLastSeq, ulong actualLastSeq)
|
||||||
=> expectedLastSeq == 0 || expectedLastSeq == actualLastSeq;
|
=> expectedLastSeq == 0 || expectedLastSeq == actualLastSeq;
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ internal static class AeadEncryptor
|
|||||||
/// Encrypts <paramref name="plaintext"/> with the given <paramref name="cipher"/>
|
/// Encrypts <paramref name="plaintext"/> with the given <paramref name="cipher"/>
|
||||||
/// and <paramref name="key"/>.
|
/// and <paramref name="key"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="plaintext">Plain JetStream block bytes to protect at rest.</param>
|
||||||
|
/// <param name="key">32-byte encryption key derived for the store context.</param>
|
||||||
|
/// <param name="cipher">Configured at-rest cipher selection.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// Wire format: <c>[12:nonce][16:tag][N:ciphertext]</c>
|
/// Wire format: <c>[12:nonce][16:tag][N:ciphertext]</c>
|
||||||
/// </returns>
|
/// </returns>
|
||||||
@@ -112,6 +115,9 @@ internal static class AeadEncryptor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decrypts data produced by <see cref="Encrypt"/>.
|
/// Decrypts data produced by <see cref="Encrypt"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="encrypted">Encrypted block bytes in nonce/tag/ciphertext wire format.</param>
|
||||||
|
/// <param name="key">32-byte encryption key used when the block was encrypted.</param>
|
||||||
|
/// <param name="cipher">Configured at-rest cipher selection.</param>
|
||||||
/// <returns>Plaintext bytes.</returns>
|
/// <returns>Plaintext bytes.</returns>
|
||||||
/// <exception cref="ArgumentException">If key length is not 32 bytes or data is too short.</exception>
|
/// <exception cref="ArgumentException">If key length is not 32 bytes or data is too short.</exception>
|
||||||
/// <exception cref="CryptographicException">If authentication tag verification fails.</exception>
|
/// <exception cref="CryptographicException">If authentication tag verification fails.</exception>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public static class AtomicFileWriter
|
|||||||
/// The data is written to a unique <c>{path}.{random}.tmp</c> sibling, flushed,
|
/// The data is written to a unique <c>{path}.{random}.tmp</c> sibling, flushed,
|
||||||
/// then renamed over <paramref name="path"/> with overwrite semantics.
|
/// then renamed over <paramref name="path"/> with overwrite semantics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="path">Target file path to replace atomically.</param>
|
||||||
|
/// <param name="data">Binary payload bytes to persist.</param>
|
||||||
public static async Task WriteAtomicallyAsync(string path, byte[] data)
|
public static async Task WriteAtomicallyAsync(string path, byte[] data)
|
||||||
{
|
{
|
||||||
var tmpPath = path + "." + Path.GetRandomFileName() + ".tmp";
|
var tmpPath = path + "." + Path.GetRandomFileName() + ".tmp";
|
||||||
@@ -35,6 +37,8 @@ public static class AtomicFileWriter
|
|||||||
/// The data is written to a unique <c>{path}.{random}.tmp</c> sibling, flushed,
|
/// The data is written to a unique <c>{path}.{random}.tmp</c> sibling, flushed,
|
||||||
/// then renamed over <paramref name="path"/> with overwrite semantics.
|
/// then renamed over <paramref name="path"/> with overwrite semantics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="path">Target file path to replace atomically.</param>
|
||||||
|
/// <param name="data">Binary payload bytes to persist.</param>
|
||||||
public static async Task WriteAtomicallyAsync(string path, ReadOnlyMemory<byte> data)
|
public static async Task WriteAtomicallyAsync(string path, ReadOnlyMemory<byte> data)
|
||||||
{
|
{
|
||||||
var tmpPath = path + "." + Path.GetRandomFileName() + ".tmp";
|
var tmpPath = path + "." + Path.GetRandomFileName() + ".tmp";
|
||||||
|
|||||||
@@ -26,16 +26,28 @@ public record struct Pending(ulong Sequence, long Timestamp);
|
|||||||
public sealed class ConsumerState
|
public sealed class ConsumerState
|
||||||
{
|
{
|
||||||
// Go: ConsumerState.Delivered — highest consumer-seq and stream-seq delivered
|
// Go: ConsumerState.Delivered — highest consumer-seq and stream-seq delivered
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets highest delivered consumer/stream sequence pair.
|
||||||
|
/// </summary>
|
||||||
public SequencePair Delivered { get; set; }
|
public SequencePair Delivered { get; set; }
|
||||||
|
|
||||||
// Go: ConsumerState.AckFloor — highest consumer-seq and stream-seq fully acknowledged
|
// Go: ConsumerState.AckFloor — highest consumer-seq and stream-seq fully acknowledged
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets highest fully acknowledged consumer/stream sequence pair.
|
||||||
|
/// </summary>
|
||||||
public SequencePair AckFloor { get; set; }
|
public SequencePair AckFloor { get; set; }
|
||||||
|
|
||||||
// Go: ConsumerState.Pending — pending acks keyed by stream sequence; only present
|
// Go: ConsumerState.Pending — pending acks keyed by stream sequence; only present
|
||||||
// when AckPolicy is Explicit.
|
// when AckPolicy is Explicit.
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets pending explicit-ack entries keyed by stream sequence.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<ulong, Pending>? Pending { get; set; }
|
public Dictionary<ulong, Pending>? Pending { get; set; }
|
||||||
|
|
||||||
// Go: ConsumerState.Redelivered — redelivery counts keyed by stream sequence;
|
// Go: ConsumerState.Redelivered — redelivery counts keyed by stream sequence;
|
||||||
// only present when a message has been delivered more than once.
|
// only present when a message has been delivered more than once.
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets redelivery counters keyed by stream sequence.
|
||||||
|
/// </summary>
|
||||||
public Dictionary<ulong, ulong>? Redelivered { get; set; }
|
public Dictionary<ulong, ulong>? Redelivered { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public static class ConsumerStateCodec
|
|||||||
/// Encodes consumer state into the Go-compatible binary format.
|
/// Encodes consumer state into the Go-compatible binary format.
|
||||||
/// Reference: golang/nats-server/server/store.go:397
|
/// Reference: golang/nats-server/server/store.go:397
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="state">Consumer state to serialize.</param>
|
||||||
public static byte[] Encode(ConsumerState state)
|
public static byte[] Encode(ConsumerState state)
|
||||||
{
|
{
|
||||||
// Upper-bound the buffer size.
|
// Upper-bound the buffer size.
|
||||||
@@ -105,6 +106,7 @@ public static class ConsumerStateCodec
|
|||||||
/// Decodes consumer state from the Go-compatible binary format.
|
/// Decodes consumer state from the Go-compatible binary format.
|
||||||
/// Reference: golang/nats-server/server/filestore.go:12216
|
/// Reference: golang/nats-server/server/filestore.go:12216
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="buf">Binary payload bytes containing encoded consumer state.</param>
|
||||||
public static ConsumerState Decode(ReadOnlySpan<byte> buf)
|
public static ConsumerState Decode(ReadOnlySpan<byte> buf)
|
||||||
{
|
{
|
||||||
// Copy to array first so lambdas can capture without ref-type restrictions.
|
// Copy to array first so lambdas can capture without ref-type restrictions.
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ namespace NATS.Server.JetStream.Storage;
|
|||||||
|
|
||||||
public sealed class FileStoreBlock
|
public sealed class FileStoreBlock
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the logical block identifier within the stream file-store layout.
|
||||||
|
/// </summary>
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the filesystem path to the backing block file.
|
||||||
|
/// </summary>
|
||||||
public required string Path { get; init; }
|
public required string Path { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the first stream sequence represented by this block.
|
||||||
|
/// </summary>
|
||||||
public ulong Sequence { get; init; }
|
public ulong Sequence { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the byte offset of this block relative to stream storage bookkeeping.
|
||||||
|
/// </summary>
|
||||||
public long OffsetBytes { get; init; }
|
public long OffsetBytes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the current block size in bytes.
|
||||||
|
/// </summary>
|
||||||
public long SizeBytes { get; set; }
|
public long SizeBytes { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,35 +11,50 @@ namespace NATS.Server.JetStream.Storage;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FileStoreConfig
|
public sealed class FileStoreConfig
|
||||||
{
|
{
|
||||||
// Go: FileStoreConfig.StoreDir — root directory for all stream block files
|
/// <summary>
|
||||||
|
/// Gets or sets the root directory used to persist JetStream stream and consumer state on disk.
|
||||||
|
/// </summary>
|
||||||
public string StoreDir { get; set; } = string.Empty;
|
public string StoreDir { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Go: FileStoreConfig.BlockSize — maximum bytes per message block file.
|
/// <summary>
|
||||||
// 0 means use the engine default (currently 8 MiB in Go).
|
/// Gets or sets the maximum size of each JetStream message block file in bytes.
|
||||||
|
/// Use <c>0</c> to apply the server default block size.
|
||||||
|
/// </summary>
|
||||||
public ulong BlockSize { get; set; }
|
public ulong BlockSize { get; set; }
|
||||||
|
|
||||||
// Go: FileStoreConfig.CacheExpire — how long to keep a loaded block in memory
|
/// <summary>
|
||||||
// after the last read before evicting. Default: 10 seconds.
|
/// Gets or sets how long a loaded block stays in memory after last access before eviction.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan CacheExpire { get; set; } = TimeSpan.FromSeconds(10);
|
public TimeSpan CacheExpire { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
// Go: FileStoreConfig.SubjectStateExpire — how long to keep per-subject state cached
|
/// <summary>
|
||||||
// on an idle message block. Zero means use CacheExpire.
|
/// Gets or sets how long per-subject accounting metadata remains cached for idle blocks.
|
||||||
|
/// When set to <see cref="TimeSpan.Zero"/>, <see cref="CacheExpire"/> is used.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan SubjectStateExpire { get; set; }
|
public TimeSpan SubjectStateExpire { get; set; }
|
||||||
|
|
||||||
// Go: FileStoreConfig.SyncInterval — interval at which dirty blocks are fsynced.
|
/// <summary>
|
||||||
// Default: 2 minutes.
|
/// Gets or sets how frequently dirty file-store blocks are synchronized to durable storage.
|
||||||
|
/// </summary>
|
||||||
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(2);
|
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
// Go: FileStoreConfig.SyncAlways — when true every write is immediately fsynced
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether each write is immediately synced for maximum durability.
|
||||||
|
/// </summary>
|
||||||
public bool SyncAlways { get; set; }
|
public bool SyncAlways { get; set; }
|
||||||
|
|
||||||
// Go: FileStoreConfig.AsyncFlush — when true write operations are batched and
|
/// <summary>
|
||||||
// flushed asynchronously for higher throughput
|
/// Gets or sets a value indicating whether flushes are performed asynchronously to improve throughput.
|
||||||
|
/// </summary>
|
||||||
public bool AsyncFlush { get; set; }
|
public bool AsyncFlush { get; set; }
|
||||||
|
|
||||||
// Go: FileStoreConfig.Cipher — cipher used for at-rest encryption; NoCipher disables it
|
/// <summary>
|
||||||
|
/// Gets or sets the encryption mode used for JetStream data at rest.
|
||||||
|
/// </summary>
|
||||||
public StoreCipher Cipher { get; set; } = StoreCipher.NoCipher;
|
public StoreCipher Cipher { get; set; } = StoreCipher.NoCipher;
|
||||||
|
|
||||||
// Go: FileStoreConfig.Compression — compression algorithm applied to block data
|
/// <summary>
|
||||||
|
/// Gets or sets the compression mode applied to persisted JetStream block payloads.
|
||||||
|
/// </summary>
|
||||||
public StoreCompression Compression { get; set; } = StoreCompression.NoCompression;
|
public StoreCompression Compression { get; set; } = StoreCompression.NoCompression;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ namespace NATS.Server.JetStream.Validation;
|
|||||||
|
|
||||||
public static class JetStreamConfigValidator
|
public static class JetStreamConfigValidator
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates stream/consumer names against JetStream naming constraints.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Candidate name string.</param>
|
||||||
public static bool IsValidName(string? name)
|
public static bool IsValidName(string? name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
@@ -25,9 +29,17 @@ public static class JetStreamConfigValidator
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when metadata key/value bytes are within JetStream size limits.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="metadata">Metadata dictionary to measure.</param>
|
||||||
public static bool IsMetadataWithinLimit(Dictionary<string, string>? metadata)
|
public static bool IsMetadataWithinLimit(Dictionary<string, string>? metadata)
|
||||||
=> MetadataByteSize(metadata) <= JetStreamApiLimits.JSMaxMetadataLen;
|
=> MetadataByteSize(metadata) <= JetStreamApiLimits.JSMaxMetadataLen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes UTF-8 byte size for all metadata keys and values.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="metadata">Metadata dictionary to measure.</param>
|
||||||
public static int MetadataByteSize(Dictionary<string, string>? metadata)
|
public static int MetadataByteSize(Dictionary<string, string>? metadata)
|
||||||
{
|
{
|
||||||
if (metadata is null || metadata.Count == 0)
|
if (metadata is null || metadata.Count == 0)
|
||||||
@@ -43,6 +55,10 @@ public static class JetStreamConfigValidator
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a stream configuration for required fields and basic limit semantics.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">Stream configuration to validate.</param>
|
||||||
public static ValidationResult Validate(StreamConfig config)
|
public static ValidationResult Validate(StreamConfig config)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(config.Name) || config.Subjects.Count == 0)
|
if (string.IsNullOrWhiteSpace(config.Name) || config.Subjects.Count == 0)
|
||||||
@@ -66,6 +82,7 @@ public static class JetStreamConfigValidator
|
|||||||
/// both server_name and cluster.name must be set.
|
/// both server_name and cluster.name must be set.
|
||||||
/// Reference: Go server/jetstream.go validateOptions (line ~2822-2831).
|
/// Reference: Go server/jetstream.go validateOptions (line ~2822-2831).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="options">Server options containing JetStream and cluster settings.</param>
|
||||||
public static ValidationResult ValidateClusterConfig(NatsOptions options)
|
public static ValidationResult ValidateClusterConfig(NatsOptions options)
|
||||||
{
|
{
|
||||||
// If JetStream is not enabled or not clustered, no cluster-specific checks needed.
|
// If JetStream is not enabled or not clustered, no cluster-specific checks needed.
|
||||||
@@ -84,7 +101,9 @@ public static class JetStreamConfigValidator
|
|||||||
|
|
||||||
public sealed class ValidationResult
|
public sealed class ValidationResult
|
||||||
{
|
{
|
||||||
|
/// <summary>Indicates whether validation succeeded.</summary>
|
||||||
public bool IsValid { get; }
|
public bool IsValid { get; }
|
||||||
|
/// <summary>Validation error message when <see cref="IsValid"/> is false.</summary>
|
||||||
public string Message { get; }
|
public string Message { get; }
|
||||||
|
|
||||||
private ValidationResult(bool isValid, string message)
|
private ValidationResult(bool isValid, string message)
|
||||||
@@ -93,6 +112,9 @@ public sealed class ValidationResult
|
|||||||
Message = message;
|
Message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a successful validation result.</summary>
|
||||||
public static ValidationResult Valid() => new(true, string.Empty);
|
public static ValidationResult Valid() => new(true, string.Empty);
|
||||||
|
/// <summary>Creates a failed validation result with an explanatory message.</summary>
|
||||||
|
/// <param name="message">Validation failure reason.</param>
|
||||||
public static ValidationResult Invalid(string message) => new(false, message);
|
public static ValidationResult Invalid(string message) => new(false, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,33 +8,43 @@ namespace NATS.Server.LeafNodes;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LeafConnectInfo
|
public sealed class LeafConnectInfo
|
||||||
{
|
{
|
||||||
|
/// <summary>Optional user JWT presented during leaf authentication.</summary>
|
||||||
[JsonPropertyName("jwt")]
|
[JsonPropertyName("jwt")]
|
||||||
public string? Jwt { get; init; }
|
public string? Jwt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Client public NKey used for nonce-signature authentication.</summary>
|
||||||
[JsonPropertyName("nkey")]
|
[JsonPropertyName("nkey")]
|
||||||
public string? Nkey { get; init; }
|
public string? Nkey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Nonce signature proving ownership of <see cref="Nkey"/>.</summary>
|
||||||
[JsonPropertyName("sig")]
|
[JsonPropertyName("sig")]
|
||||||
public string? Sig { get; init; }
|
public string? Sig { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether this leaf connection advertises hub mode support.</summary>
|
||||||
[JsonPropertyName("hub")]
|
[JsonPropertyName("hub")]
|
||||||
public bool Hub { get; init; }
|
public bool Hub { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional cluster name associated with the remote leaf node.</summary>
|
||||||
[JsonPropertyName("cluster")]
|
[JsonPropertyName("cluster")]
|
||||||
public string? Cluster { get; init; }
|
public string? Cluster { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether protocol headers are supported on this leaf connection.</summary>
|
||||||
[JsonPropertyName("headers")]
|
[JsonPropertyName("headers")]
|
||||||
public bool Headers { get; init; }
|
public bool Headers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether JetStream asset and API forwarding are supported.</summary>
|
||||||
[JsonPropertyName("jetstream")]
|
[JsonPropertyName("jetstream")]
|
||||||
public bool JetStream { get; init; }
|
public bool JetStream { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Negotiated compression mode for leaf traffic.</summary>
|
||||||
[JsonPropertyName("compression")]
|
[JsonPropertyName("compression")]
|
||||||
public string? Compression { get; init; }
|
public string? Compression { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional remote account binding for this solicited leaf link.</summary>
|
||||||
[JsonPropertyName("remote_account")]
|
[JsonPropertyName("remote_account")]
|
||||||
public string? RemoteAccount { get; init; }
|
public string? RemoteAccount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Leaf protocol version number.</summary>
|
||||||
[JsonPropertyName("proto")]
|
[JsonPropertyName("proto")]
|
||||||
public int Proto { get; init; }
|
public int Proto { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ public sealed class LeafHubSpokeMapper
|
|||||||
private readonly IReadOnlyList<string> _allowExports;
|
private readonly IReadOnlyList<string> _allowExports;
|
||||||
private readonly IReadOnlyList<string> _allowImports;
|
private readonly IReadOnlyList<string> _allowImports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a mapper with account mapping only (no subject allow/deny filters).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hubToSpoke">Mapping from hub account names to spoke account names.</param>
|
||||||
public LeafHubSpokeMapper(IReadOnlyDictionary<string, string> hubToSpoke)
|
public LeafHubSpokeMapper(IReadOnlyDictionary<string, string> hubToSpoke)
|
||||||
: this(hubToSpoke, [], [], [], [])
|
: this(hubToSpoke, [], [], [], [])
|
||||||
{
|
{
|
||||||
@@ -40,6 +44,9 @@ public sealed class LeafHubSpokeMapper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a mapper with account mapping and subject deny filters (legacy constructor).
|
/// Creates a mapper with account mapping and subject deny filters (legacy constructor).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="hubToSpoke">Mapping from hub account names to spoke account names.</param>
|
||||||
|
/// <param name="denyExports">Subject patterns denied for hub→leaf flow.</param>
|
||||||
|
/// <param name="denyImports">Subject patterns denied for leaf→hub flow.</param>
|
||||||
public LeafHubSpokeMapper(
|
public LeafHubSpokeMapper(
|
||||||
IReadOnlyDictionary<string, string> hubToSpoke,
|
IReadOnlyDictionary<string, string> hubToSpoke,
|
||||||
IReadOnlyList<string> denyExports,
|
IReadOnlyList<string> denyExports,
|
||||||
@@ -74,6 +81,9 @@ public sealed class LeafHubSpokeMapper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps an account from hub→spoke or spoke→hub based on direction.
|
/// Maps an account from hub→spoke or spoke→hub based on direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="account">Account name to map.</param>
|
||||||
|
/// <param name="subject">Subject associated with the mapping request.</param>
|
||||||
|
/// <param name="direction">Flow direction determining which map to apply.</param>
|
||||||
public LeafMappingResult Map(string account, string subject, LeafMapDirection direction)
|
public LeafMappingResult Map(string account, string subject, LeafMapDirection direction)
|
||||||
{
|
{
|
||||||
if (direction == LeafMapDirection.Outbound && _hubToSpoke.TryGetValue(account, out var spoke))
|
if (direction == LeafMapDirection.Outbound && _hubToSpoke.TryGetValue(account, out var spoke))
|
||||||
@@ -89,6 +99,8 @@ public sealed class LeafHubSpokeMapper
|
|||||||
/// When an allow-list is set, the subject must also match at least one allow pattern.
|
/// When an allow-list is set, the subject must also match at least one allow pattern.
|
||||||
/// Deny takes precedence over allow (Go reference: auth.go SubjectPermission semantics).
|
/// Deny takes precedence over allow (Go reference: auth.go SubjectPermission semantics).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="subject">Subject to evaluate.</param>
|
||||||
|
/// <param name="direction">Flow direction used to choose allow/deny lists.</param>
|
||||||
public bool IsSubjectAllowed(string subject, LeafMapDirection direction)
|
public bool IsSubjectAllowed(string subject, LeafMapDirection direction)
|
||||||
{
|
{
|
||||||
var (denyList, allowList) = direction switch
|
var (denyList, allowList) = direction switch
|
||||||
|
|||||||
@@ -4,15 +4,34 @@ public static class LeafLoopDetector
|
|||||||
{
|
{
|
||||||
private const string LeafLoopPrefix = "$LDS.";
|
private const string LeafLoopPrefix = "$LDS.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a subject contains the leaf-loop marker prefix.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Subject to inspect for loop marker metadata.</param>
|
||||||
public static bool HasLoopMarker(string subject)
|
public static bool HasLoopMarker(string subject)
|
||||||
=> subject.StartsWith(LeafLoopPrefix, StringComparison.Ordinal);
|
=> subject.StartsWith(LeafLoopPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prefixes a subject with local loop-detection metadata for leaf forwarding.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Original subject being forwarded.</param>
|
||||||
|
/// <param name="serverId">Server identifier appended to the loop marker.</param>
|
||||||
public static string Mark(string subject, string serverId)
|
public static string Mark(string subject, string serverId)
|
||||||
=> $"{LeafLoopPrefix}{serverId}.{subject}";
|
=> $"{LeafLoopPrefix}{serverId}.{subject}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the subject indicates a loop back to the local server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Forwarded subject containing loop markers.</param>
|
||||||
|
/// <param name="localServerId">Current server identifier.</param>
|
||||||
public static bool IsLooped(string subject, string localServerId)
|
public static bool IsLooped(string subject, string localServerId)
|
||||||
=> subject.StartsWith($"{LeafLoopPrefix}{localServerId}.", StringComparison.Ordinal);
|
=> subject.StartsWith($"{LeafLoopPrefix}{localServerId}.", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all loop markers from a subject and returns the unmarked subject.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Subject that may contain one or more loop markers.</param>
|
||||||
|
/// <param name="unmarked">Unmarked subject when removal succeeds.</param>
|
||||||
public static bool TryUnmark(string subject, out string unmarked)
|
public static bool TryUnmark(string subject, out string unmarked)
|
||||||
{
|
{
|
||||||
unmarked = subject;
|
unmarked = subject;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public static class LeafSubKey
|
|||||||
public static readonly TimeSpan SharedSysAccDelay = TimeSpan.FromMilliseconds(250);
|
public static readonly TimeSpan SharedSysAccDelay = TimeSpan.FromMilliseconds(250);
|
||||||
public static readonly TimeSpan ConnectProcessTimeout = TimeSpan.FromSeconds(2);
|
public static readonly TimeSpan ConnectProcessTimeout = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds canonical subscription key from subject and optional queue group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sub">Subscription used to build key components.</param>
|
||||||
public static string KeyFromSub(Subscription sub)
|
public static string KeyFromSub(Subscription sub)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sub);
|
ArgumentNullException.ThrowIfNull(sub);
|
||||||
@@ -24,6 +28,11 @@ public static class LeafSubKey
|
|||||||
: sub.Subject;
|
: sub.Subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds canonical subscription key including routed-origin metadata when present.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sub">Subscription used to build key components.</param>
|
||||||
|
/// <param name="origin">Optional routed origin identifier.</param>
|
||||||
public static string KeyFromSubWithOrigin(Subscription sub, string? origin = null)
|
public static string KeyFromSubWithOrigin(Subscription sub, string? origin = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sub);
|
ArgumentNullException.ThrowIfNull(sub);
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ public sealed class WebSocketStreamAdapter : Stream
|
|||||||
private int _readCount;
|
private int _readCount;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a stream adapter for a WebSocket-backed leaf-node transport.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ws">WebSocket transport used for framed binary I/O.</param>
|
||||||
|
/// <param name="initialBufferSize">Initial receive staging-buffer size.</param>
|
||||||
public WebSocketStreamAdapter(SystemWebSocket ws, int initialBufferSize = 4096)
|
public WebSocketStreamAdapter(SystemWebSocket ws, int initialBufferSize = 4096)
|
||||||
{
|
{
|
||||||
_ws = ws ?? throw new ArgumentNullException(nameof(ws));
|
_ws = ws ?? throw new ArgumentNullException(nameof(ws));
|
||||||
@@ -34,10 +39,15 @@ public sealed class WebSocketStreamAdapter : Stream
|
|||||||
public override bool CanSeek => false;
|
public override bool CanSeek => false;
|
||||||
|
|
||||||
// Telemetry properties
|
// Telemetry properties
|
||||||
|
/// <summary>Whether the underlying WebSocket is currently open.</summary>
|
||||||
public bool IsConnected => _ws.State == WebSocketState.Open;
|
public bool IsConnected => _ws.State == WebSocketState.Open;
|
||||||
|
/// <summary>Total bytes read from received WebSocket messages.</summary>
|
||||||
public long BytesRead { get; private set; }
|
public long BytesRead { get; private set; }
|
||||||
|
/// <summary>Total bytes written to outbound WebSocket messages.</summary>
|
||||||
public long BytesWritten { get; private set; }
|
public long BytesWritten { get; private set; }
|
||||||
|
/// <summary>Total completed WebSocket messages read.</summary>
|
||||||
public int MessagesRead { get; private set; }
|
public int MessagesRead { get; private set; }
|
||||||
|
/// <summary>Total completed WebSocket messages written.</summary>
|
||||||
public int MessagesWritten { get; private set; }
|
public int MessagesWritten { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -196,12 +206,18 @@ public sealed class WebSocketStreamAdapter : Stream
|
|||||||
get => throw new NotSupportedException();
|
get => throw new NotSupportedException();
|
||||||
set => throw new NotSupportedException();
|
set => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
/// <inheritdoc />
|
||||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override void SetLength(long value) => throw new NotSupportedException();
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
/// <inheritdoc />
|
||||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException("Use async methods");
|
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException("Use async methods");
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException("Use async methods");
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException("Use async methods");
|
||||||
|
/// <inheritdoc />
|
||||||
public override void Flush() { }
|
public override void Flush() { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ public sealed class AccountzHandler
|
|||||||
{
|
{
|
||||||
private readonly NatsServer _server;
|
private readonly NatsServer _server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates handler for account-focused monitoring endpoints.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">Server instance providing account snapshots.</param>
|
||||||
public AccountzHandler(NatsServer server)
|
public AccountzHandler(NatsServer server)
|
||||||
{
|
{
|
||||||
_server = server;
|
_server = server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds account overview payload for <c>/accountz</c>.
|
||||||
|
/// </summary>
|
||||||
public object Build()
|
public object Build()
|
||||||
{
|
{
|
||||||
var accounts = _server.GetAccounts().Select(ToAccountDto).ToArray();
|
var accounts = _server.GetAccounts().Select(ToAccountDto).ToArray();
|
||||||
@@ -21,6 +28,9 @@ public sealed class AccountzHandler
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds aggregate account statistics payload for <c>/accstatz</c>.
|
||||||
|
/// </summary>
|
||||||
public object BuildStats()
|
public object BuildStats()
|
||||||
{
|
{
|
||||||
var accounts = _server.GetAccounts().ToArray();
|
var accounts = _server.GetAccounts().ToArray();
|
||||||
|
|||||||
@@ -13,19 +13,32 @@ public sealed class ClosedConnectionRingBuffer
|
|||||||
private int _count; // Current count (up to capacity)
|
private int _count; // Current count (up to capacity)
|
||||||
private long _totalClosed; // Running total of all closed connections ever
|
private long _totalClosed; // Running total of all closed connections ever
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a fixed-size closed-connection ring buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="capacity">Maximum number of recent closed-client snapshots retained.</param>
|
||||||
public ClosedConnectionRingBuffer(int capacity = 1024)
|
public ClosedConnectionRingBuffer(int capacity = 1024)
|
||||||
{
|
{
|
||||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero.");
|
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero.");
|
||||||
_buffer = new ClosedClient[capacity];
|
_buffer = new ClosedClient[capacity];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum number of closed-client entries retained before wraparound.
|
||||||
|
/// </summary>
|
||||||
public int Capacity => _buffer.Length;
|
public int Capacity => _buffer.Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of currently retained closed-client entries.
|
||||||
|
/// </summary>
|
||||||
public int Count
|
public int Count
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _count; }
|
get { lock (_lock) return _count; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the lifetime total count of closed connections observed by this buffer.
|
||||||
|
/// </summary>
|
||||||
public long TotalClosed
|
public long TotalClosed
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _totalClosed; }
|
get { lock (_lock) return _totalClosed; }
|
||||||
@@ -34,6 +47,7 @@ public sealed class ClosedConnectionRingBuffer
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a closed connection snapshot. If the buffer is full the oldest entry is overwritten.
|
/// Adds a closed connection snapshot. If the buffer is full the oldest entry is overwritten.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="info">Closed-client snapshot to append into the ring.</param>
|
||||||
public void Add(ClosedClient info)
|
public void Add(ClosedClient info)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -60,6 +74,7 @@ public sealed class ClosedConnectionRingBuffer
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns up to <paramref name="count"/> most recent entries, ordered newest-first.
|
/// Returns up to <paramref name="count"/> most recent entries, ordered newest-first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="count">Maximum number of recent entries to return.</param>
|
||||||
public IReadOnlyList<ClosedClient> GetRecent(int count)
|
public IReadOnlyList<ClosedClient> GetRecent(int count)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ public sealed class GatewayzHandler
|
|||||||
{
|
{
|
||||||
private readonly NatsServer _server;
|
private readonly NatsServer _server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates gateway monitoring handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">Server instance providing gateway metrics.</param>
|
||||||
public GatewayzHandler(NatsServer server)
|
public GatewayzHandler(NatsServer server)
|
||||||
{
|
{
|
||||||
_server = server;
|
_server = server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds gateway metrics payload for <c>/gatewayz</c>.
|
||||||
|
/// </summary>
|
||||||
public object Build()
|
public object Build()
|
||||||
{
|
{
|
||||||
var gateways = _server.Stats.Gateways;
|
var gateways = _server.Stats.Gateways;
|
||||||
|
|||||||
@@ -8,18 +8,33 @@ namespace NATS.Server.Monitoring;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealthStatus
|
public sealed class HealthStatus
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the overall health status string returned by the monitoring endpoint.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("status")]
|
[JsonPropertyName("status")]
|
||||||
public string Status { get; init; } = "ok";
|
public string Status { get; init; } = "ok";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the HTTP-style status code representing current server health.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("status_code")]
|
[JsonPropertyName("status_code")]
|
||||||
public int StatusCode { get; init; } = 200;
|
public int StatusCode { get; init; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the top-level error message when health checks fail.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("error")]
|
[JsonPropertyName("error")]
|
||||||
public string? Error { get; init; }
|
public string? Error { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets detailed health-check failures contributing to a non-OK status.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("errors")]
|
[JsonPropertyName("errors")]
|
||||||
public HealthzError[] Errors { get; init; } = [];
|
public HealthzError[] Errors { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a successful health response payload used by <c>/healthz</c>.
|
||||||
|
/// </summary>
|
||||||
public static HealthStatus Ok() => new();
|
public static HealthStatus Ok() => new();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,9 +44,15 @@ public sealed class HealthStatus
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealthzError
|
public sealed class HealthzError
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the subsystem classification for this health failure.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public HealthzErrorType Type { get; init; } = HealthzErrorType.Unknown;
|
public HealthzErrorType Type { get; init; } = HealthzErrorType.Unknown;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the subsystem-specific failure detail emitted for diagnostics.
|
||||||
|
/// </summary>
|
||||||
[JsonPropertyName("error")]
|
[JsonPropertyName("error")]
|
||||||
public string Error { get; init; } = string.Empty;
|
public string Error { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ public sealed class JszHandler
|
|||||||
private readonly NatsServer _server;
|
private readonly NatsServer _server;
|
||||||
private readonly NatsOptions _options;
|
private readonly NatsOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a JetStream monitoring response builder bound to server runtime state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">Running server instance exposing JetStream counters and IDs.</param>
|
||||||
|
/// <param name="options">Server options containing JetStream capacity configuration.</param>
|
||||||
public JszHandler(NatsServer server, NatsOptions options)
|
public JszHandler(NatsServer server, NatsOptions options)
|
||||||
{
|
{
|
||||||
_server = server;
|
_server = server;
|
||||||
_options = options;
|
_options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a point-in-time <c>/jsz</c> style response from current server state.
|
||||||
|
/// </summary>
|
||||||
public JszResponse Build()
|
public JszResponse Build()
|
||||||
{
|
{
|
||||||
return new JszResponse
|
return new JszResponse
|
||||||
@@ -38,33 +46,43 @@ public sealed class JszHandler
|
|||||||
|
|
||||||
public sealed class JszResponse
|
public sealed class JszResponse
|
||||||
{
|
{
|
||||||
|
/// <summary>Server identifier for the node producing this response.</summary>
|
||||||
[JsonPropertyName("server_id")]
|
[JsonPropertyName("server_id")]
|
||||||
public string ServerId { get; set; } = string.Empty;
|
public string ServerId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when this monitoring snapshot was generated.</summary>
|
||||||
[JsonPropertyName("now")]
|
[JsonPropertyName("now")]
|
||||||
public DateTime Now { get; set; }
|
public DateTime Now { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether JetStream is enabled on this server.</summary>
|
||||||
[JsonPropertyName("enabled")]
|
[JsonPropertyName("enabled")]
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JetStream memory usage in bytes.</summary>
|
||||||
[JsonPropertyName("memory")]
|
[JsonPropertyName("memory")]
|
||||||
public ulong Memory { get; set; }
|
public ulong Memory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>JetStream file-storage usage in bytes.</summary>
|
||||||
[JsonPropertyName("storage")]
|
[JsonPropertyName("storage")]
|
||||||
public ulong Storage { get; set; }
|
public ulong Storage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of JetStream streams currently hosted.</summary>
|
||||||
[JsonPropertyName("streams")]
|
[JsonPropertyName("streams")]
|
||||||
public int Streams { get; set; }
|
public int Streams { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of JetStream consumers currently hosted.</summary>
|
||||||
[JsonPropertyName("consumers")]
|
[JsonPropertyName("consumers")]
|
||||||
public int Consumers { get; set; }
|
public int Consumers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total number of JetStream API requests handled.</summary>
|
||||||
[JsonPropertyName("api_total")]
|
[JsonPropertyName("api_total")]
|
||||||
public ulong ApiTotal { get; set; }
|
public ulong ApiTotal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total number of JetStream API requests that returned errors.</summary>
|
||||||
[JsonPropertyName("api_errors")]
|
[JsonPropertyName("api_errors")]
|
||||||
public ulong ApiErrors { get; set; }
|
public ulong ApiErrors { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Configured JetStream resource limits and storage directory.</summary>
|
||||||
[JsonPropertyName("config")]
|
[JsonPropertyName("config")]
|
||||||
public JetStreamConfig Config { get; set; } = new();
|
public JetStreamConfig Config { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ public sealed class LeafzHandler
|
|||||||
{
|
{
|
||||||
private readonly NatsServer _server;
|
private readonly NatsServer _server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates leaf-node monitoring handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">Server instance providing leaf metrics.</param>
|
||||||
public LeafzHandler(NatsServer server)
|
public LeafzHandler(NatsServer server)
|
||||||
{
|
{
|
||||||
_server = server;
|
_server = server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds leaf-node metrics payload for <c>/leafz</c>.
|
||||||
|
/// </summary>
|
||||||
public object Build()
|
public object Build()
|
||||||
{
|
{
|
||||||
var leafs = _server.Stats.Leafs;
|
var leafs = _server.Stats.Leafs;
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ public sealed class MonitorServer : IAsyncDisposable
|
|||||||
private readonly AccountzHandler _accountzHandler;
|
private readonly AccountzHandler _accountzHandler;
|
||||||
private readonly PprofHandler _pprofHandler;
|
private readonly PprofHandler _pprofHandler;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates monitoring HTTP server wiring and endpoint handlers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">Server runtime state used by monitoring handlers.</param>
|
||||||
|
/// <param name="options">Monitoring and feature options controlling endpoint behavior.</param>
|
||||||
|
/// <param name="stats">Shared HTTP request stats counters.</param>
|
||||||
|
/// <param name="loggerFactory">Logger factory for monitor diagnostics.</param>
|
||||||
public MonitorServer(NatsServer server, NatsOptions options, ServerStats stats, ILoggerFactory loggerFactory)
|
public MonitorServer(NatsServer server, NatsOptions options, ServerStats stats, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<MonitorServer>();
|
_logger = loggerFactory.CreateLogger<MonitorServer>();
|
||||||
@@ -137,12 +144,19 @@ public sealed class MonitorServer : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the monitoring web server.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token for server startup.</param>
|
||||||
public async Task StartAsync(CancellationToken ct)
|
public async Task StartAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _app.StartAsync(ct);
|
await _app.StartAsync(ct);
|
||||||
_logger.LogInformation("Monitoring listening on {Urls}", string.Join(", ", _app.Urls));
|
_logger.LogInformation("Monitoring listening on {Urls}", string.Join(", ", _app.Urls));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops and disposes monitoring server resources.
|
||||||
|
/// </summary>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _app.StopAsync();
|
await _app.StopAsync();
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ namespace NATS.Server.Monitoring;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PprofHandler
|
public sealed class PprofHandler
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns index content for pprof-compatible endpoint listing.
|
||||||
|
/// </summary>
|
||||||
public string Index()
|
public string Index()
|
||||||
{
|
{
|
||||||
return """
|
return """
|
||||||
@@ -21,6 +24,10 @@ public sealed class PprofHandler
|
|||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures lightweight CPU profile metadata payload.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seconds">Requested capture duration in seconds.</param>
|
||||||
public byte[] CaptureCpuProfile(int seconds)
|
public byte[] CaptureCpuProfile(int seconds)
|
||||||
{
|
{
|
||||||
var boundedSeconds = Math.Clamp(seconds, 1, 120);
|
var boundedSeconds = Math.Clamp(seconds, 1, 120);
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ public sealed class RoutezHandler
|
|||||||
{
|
{
|
||||||
private readonly NatsServer _server;
|
private readonly NatsServer _server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates route monitoring handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">Server instance providing route metrics.</param>
|
||||||
public RoutezHandler(NatsServer server)
|
public RoutezHandler(NatsServer server)
|
||||||
{
|
{
|
||||||
_server = server;
|
_server = server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds route metrics payload for <c>/routez</c>.
|
||||||
|
/// </summary>
|
||||||
public object Build()
|
public object Build()
|
||||||
{
|
{
|
||||||
var routes = _server.Stats.Routes;
|
var routes = _server.Stats.Routes;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user