feat: add structured logging, Shouldly assertions, CPM, and project documentation

- Add Microsoft.Extensions.Logging + Serilog to NatsServer and NatsClient
- Convert all test assertions from xUnit Assert to Shouldly
- Add NSubstitute package for future mocking needs
- Introduce Central Package Management via Directory.Packages.props
- Add documentation_rules.md with style guide, generation/update rules, component map
- Generate 10 documentation files across 5 component folders (GettingStarted, Protocol, Subscriptions, Server, Configuration/Operations)
- Update CLAUDE.md with logging, testing, porting, agent model, CPM, and documentation guidance
This commit is contained in:
Joseph Doherty
2026-02-22 21:05:53 -05:00
parent b9f4dec523
commit 539b2b7588
25 changed files with 2734 additions and 110 deletions

View File

@@ -0,0 +1,169 @@
# Subscriptions Overview
The subscription system maps published subjects to interested subscribers. It consists of four classes: `Subscription` (the subscription model), `SubListResult` (a match result container), `SubjectMatch` (subject validation and wildcard matching), and `SubList` (the trie that routes messages to subscribers).
Go reference: `golang/nats-server/server/sublist.go`
---
## Subjects
A NATS subject is a dot-separated string of tokens: `foo.bar.baz`. Each token is a non-empty sequence of non-whitespace characters. The token separator is `.`.
### Wildcards
Two wildcard tokens are supported:
| Token | Name | Matches |
|-------|------|---------|
| `*` | Partial wildcard (Pwc) | Exactly one token at that position |
| `>` | Full wildcard (Fwc) | One or more remaining tokens |
Rules:
- `foo.*` matches `foo.bar` but not `foo.bar.baz` (only one token after `foo`).
- `foo.>` matches `foo.bar` and `foo.bar.baz` (one or more tokens after `foo`).
- `>` must be the last token in a subject. `foo.>.bar` is invalid.
- Publishers cannot use wildcards — only subscribers can.
### Queue Groups
Subscribers with the same queue name form a queue group. When a message matches a queue group, exactly one member of that group receives the message. The server selects a member per published message, which distributes load across the group.
Queue subscriptions are independent of plain subscriptions. If a subject has both plain subscribers and queue group subscribers, all plain subscribers receive the message and one member from each queue group receives it.
---
## The `Subscription` Class
A `Subscription` represents one subscriber's interest in a subject.
```csharp
public sealed class Subscription
{
public required string Subject { get; init; }
public string? Queue { get; init; }
public required string Sid { get; init; }
public long MessageCount; // Interlocked
public long MaxMessages; // 0 = unlimited
public NatsClient? Client { get; set; }
}
```
- `Subject` — the subject pattern, which may contain wildcards.
- `Queue` — the queue group name. `null` means this is a plain subscription.
- `Sid` — the subscription ID string assigned by the client. Used to identify the subscription in `UNSUB` commands.
- `MessageCount` — updated with `Interlocked.Increment` on each delivered message. Compared against `MaxMessages` to implement `UNSUB maxMessages`.
- `MaxMessages` — the auto-unsubscribe limit. `0` means unlimited.
- `Client` — back-reference to the `NatsClient` that owns this subscription. Used during message delivery to write `MSG` frames to the connection.
---
## The `SubListResult` Class
`SubListResult` is the return type of `SubList.Match()`. It separates plain subscribers from queue group subscribers.
```csharp
public sealed class SubListResult
{
public static readonly SubListResult Empty = new([], []);
public Subscription[] PlainSubs { get; }
public Subscription[][] QueueSubs { get; } // outer = groups, inner = members
public SubListResult(Subscription[] plainSubs, Subscription[][] queueSubs)
{
PlainSubs = plainSubs;
QueueSubs = queueSubs;
}
}
```
- `PlainSubs` — all plain subscribers whose subject pattern matches the published subject.
- `QueueSubs` — one entry per queue group. Each inner array contains all members of that group. The message router picks one member per group.
- `Empty` — a static singleton returned when no subscriptions match. Avoids allocation on the common case of no interest.
---
## The `SubjectMatch` Class
`SubjectMatch` is a static utility class for subject validation and wildcard pattern matching. It defines the three fundamental constants used throughout the subscription system:
```csharp
public static class SubjectMatch
{
public const char Pwc = '*'; // partial wildcard
public const char Fwc = '>'; // full wildcard
public const char Sep = '.'; // token separator
...
}
```
### Validation methods
**`IsValidSubject(string subject)`** — returns `true` if the subject is a well-formed NATS subject. Rejects:
- Empty strings.
- Empty tokens (consecutive separators, or leading/trailing separators).
- Tokens containing whitespace characters.
- Any token after `>` (full wildcard must be last).
**`IsLiteral(string subject)`** — returns `true` if the subject contains no wildcards. A character is treated as a wildcard only when it appears as a complete token (preceded and followed by `.` or the string boundary).
**`IsValidPublishSubject(string subject)`** — returns `true` if the subject is both valid and literal. Publishers must use literal subjects; this is the combined check applied before inserting a message into the routing path.
### Pattern matching
**`MatchLiteral(string literal, string pattern)`** — tests whether a literal subject matches a pattern that may contain wildcards. Used by the cache invalidation logic to determine which cached results are affected when a wildcard subscription is added or removed.
```csharp
public static bool MatchLiteral(string literal, string pattern)
{
int li = 0, pi = 0;
while (pi < pattern.Length)
{
// Get next pattern token
int pTokenStart = pi;
while (pi < pattern.Length && pattern[pi] != Sep)
pi++;
int pTokenLen = pi - pTokenStart;
if (pi < pattern.Length)
pi++; // skip separator
// Full wildcard -- matches everything remaining
if (pTokenLen == 1 && pattern[pTokenStart] == Fwc)
return li < literal.Length; // must have at least one token left
// Get next literal token
if (li >= literal.Length)
return false;
int lTokenStart = li;
while (li < literal.Length && literal[li] != Sep)
li++;
int lTokenLen = li - lTokenStart;
if (li < literal.Length)
li++; // skip separator
// Partial wildcard -- matches any single token
if (pTokenLen == 1 && pattern[pTokenStart] == Pwc)
continue;
// Literal comparison
if (pTokenLen != lTokenLen)
return false;
if (string.Compare(literal, lTokenStart, pattern, pTokenStart, pTokenLen, StringComparison.Ordinal) != 0)
return false;
}
return li >= literal.Length; // both exhausted
}
```
The algorithm walks both strings token by token without allocating. `>` short-circuits immediately, returning `true` as long as at least one literal token remains. `*` skips the corresponding literal token without comparison. Literal tokens are compared with `StringComparison.Ordinal`.
---
## Related Documentation
- [SubList Trie](../Subscriptions/SubList.md)
<!-- Last verified against codebase: 2026-02-22 -->