Files
natsnet/dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/GenericSublist.cs
Joseph Doherty 88b1391ef0 feat: port session 07 — Protocol Parser, Auth extras (TPM/certidp/certstore), Internal utilities & data structures
Session 07 scope (5 features, 17 tests, ~1165 Go LOC):
- Protocol/ParserTypes.cs: ParserState enum (79 states), PublishArgument, ParseContext
- Protocol/IProtocolHandler.cs: handler interface decoupling parser from client
- Protocol/ProtocolParser.cs: Parse(), ProtoSnippet(), OverMaxControlLineLimit(),
  ProcessPub/HeaderPub/RoutedMsgArgs/RoutedHeaderMsgArgs, ClonePubArg(), GetHeader()
- tests/Protocol/ProtocolParserTests.cs: 17 tests via TestProtocolHandler stub

Auth extras from session 06 (committed separately):
- Auth/TpmKeyProvider.cs, Auth/CertificateIdentityProvider/, Auth/CertificateStore/

Internal utilities & data structures (session 06 overflow):
- Internal/AccessTimeService.cs, ElasticPointer.cs, SystemMemory.cs, ProcessStatsProvider.cs
- Internal/DataStructures/GenericSublist.cs, HashWheel.cs
- Internal/DataStructures/SubjectTree.cs, SubjectTreeNode.cs, SubjectTreeParts.cs

All 461 tests pass (460 unit + 1 integration). DB updated for features 2588-2592 and tests 2598-2614.
2026-02-26 13:16:56 -05:00

679 lines
22 KiB
C#

// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
// Sublist is a routing mechanism to handle subject distribution and
// provides a facility to match subjects from published messages to
// interested subscribers. Subscribers can have wildcard subjects to
// match multiple published subjects.
/// <summary>
/// A value type used with <see cref="SimpleSublist"/> to track interest without
/// storing any associated data. Equivalent to Go's <c>struct{}</c>.
/// </summary>
public readonly struct EmptyStruct : IEquatable<EmptyStruct>
{
public static readonly EmptyStruct Value = default;
public bool Equals(EmptyStruct other) => true;
public override bool Equals(object? obj) => obj is EmptyStruct;
public override int GetHashCode() => 0;
public static bool operator ==(EmptyStruct left, EmptyStruct right) => true;
public static bool operator !=(EmptyStruct left, EmptyStruct right) => false;
}
/// <summary>
/// A thread-safe trie-based NATS subject routing list that efficiently stores and
/// retrieves subscriptions. Wildcards <c>*</c> (single-token) and <c>&gt;</c>
/// (full-wildcard) are supported.
/// </summary>
/// <typeparam name="T">The subscription value type. Must be non-null.</typeparam>
public class GenericSublist<T> where T : notnull
{
// Token separator and wildcard constants (mirrors Go's const block).
private const char Pwc = '*';
private const char Fwc = '>';
private const char Btsep = '.';
// -------------------------------------------------------------------------
// Public error singletons (mirrors Go's var block).
// -------------------------------------------------------------------------
/// <summary>Thrown when a subject is syntactically invalid.</summary>
public static readonly ArgumentException ErrInvalidSubject =
new("gsl: invalid subject");
/// <summary>Thrown when a subscription is not found during removal.</summary>
public static readonly KeyNotFoundException ErrNotFound =
new("gsl: no matches found");
/// <summary>Thrown when a value is already registered for the given subject.</summary>
public static readonly InvalidOperationException ErrAlreadyRegistered =
new("gsl: notification already registered");
// -------------------------------------------------------------------------
// Fields
// -------------------------------------------------------------------------
private readonly TrieLevel _root;
private uint _count;
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
// -------------------------------------------------------------------------
// Construction
// -------------------------------------------------------------------------
internal GenericSublist()
{
_root = new TrieLevel();
}
/// <summary>Creates a new <see cref="GenericSublist{T}"/>.</summary>
public static GenericSublist<T> NewSublist() => new();
/// <summary>Creates a new <see cref="SimpleSublist"/>.</summary>
public static SimpleSublist NewSimpleSublist() => new();
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/// <summary>Returns the total number of subscriptions stored.</summary>
public uint Count
{
get
{
_lock.EnterReadLock();
try { return _count; }
finally { _lock.ExitReadLock(); }
}
}
/// <summary>
/// Inserts a subscription into the trie.
/// Throws <see cref="ArgumentException"/> if <paramref name="subject"/> is invalid.
/// </summary>
public void Insert(string subject, T value)
{
_lock.EnterWriteLock();
try
{
InsertCore(subject, value);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// Removes a subscription from the trie.
/// Throws <see cref="ArgumentException"/> if the subject is invalid, or
/// <see cref="KeyNotFoundException"/> if not found.
/// </summary>
public void Remove(string subject, T value)
{
_lock.EnterWriteLock();
try
{
RemoveCore(subject, value);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// Calls <paramref name="action"/> for every value whose subscription matches
/// the literal <paramref name="subject"/>.
/// </summary>
public void Match(string subject, Action<T> action)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeForMatch(subject);
if (tokens == null) return;
MatchLevel(_root, tokens, 0, action);
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Calls <paramref name="action"/> for every value whose subscription matches
/// <paramref name="subject"/> supplied as a UTF-8 byte span.
/// </summary>
public void MatchBytes(ReadOnlySpan<byte> subject, Action<T> action)
{
Match(System.Text.Encoding.UTF8.GetString(subject), action);
}
/// <summary>
/// Returns <see langword="true"/> when at least one subscription matches
/// <paramref name="subject"/>.
/// </summary>
public bool HasInterest(string subject)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeForMatch(subject);
if (tokens == null) return false;
int dummy = 0;
return MatchLevelForAny(_root, tokens, 0, ref dummy);
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Returns the number of subscriptions that match <paramref name="subject"/>.
/// </summary>
public int NumInterest(string subject)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeForMatch(subject);
if (tokens == null) return 0;
int np = 0;
MatchLevelForAny(_root, tokens, 0, ref np);
return np;
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Returns <see langword="true"/> if the trie contains any subscription that
/// could match a subject whose tokens begin with the tokens of
/// <paramref name="subject"/>. Used for trie intersection checks.
/// </summary>
public bool HasInterestStartingIn(string subject)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeSubjectIntoSlice(subject);
return HasInterestStartingInLevel(_root, tokens, 0);
}
finally
{
_lock.ExitReadLock();
}
}
// -------------------------------------------------------------------------
// Internal helpers (accessible to tests in the same assembly).
// -------------------------------------------------------------------------
/// <summary>Returns the maximum depth of the trie. Used in tests.</summary>
internal int NumLevels() => VisitLevel(_root, 0);
// -------------------------------------------------------------------------
// Private: Insert core (lock must be held by caller)
// -------------------------------------------------------------------------
private void InsertCore(string subject, T value)
{
var sfwc = false; // seen full-wildcard token
TrieNode? n = null;
var l = _root;
// Iterate tokens split by '.' using index arithmetic to avoid allocations.
var start = 0;
while (start <= subject.Length)
{
// Find end of this token.
var end = subject.IndexOf(Btsep, start);
var isLast = end < 0;
if (isLast) end = subject.Length;
var tokenLen = end - start;
if (tokenLen == 0 || sfwc)
throw new ArgumentException(ErrInvalidSubject.Message);
if (tokenLen > 1)
{
var t = subject.Substring(start, tokenLen);
if (!l.Nodes.TryGetValue(t, out n))
{
n = new TrieNode();
l.Nodes[t] = n;
}
}
else
{
switch (subject[start])
{
case Pwc:
if (l.PwcNode == null) l.PwcNode = new TrieNode();
n = l.PwcNode;
break;
case Fwc:
if (l.FwcNode == null) l.FwcNode = new TrieNode();
n = l.FwcNode;
sfwc = true;
break;
default:
var t = subject.Substring(start, 1);
if (!l.Nodes.TryGetValue(t, out n))
{
n = new TrieNode();
l.Nodes[t] = n;
}
break;
}
}
n.Next ??= new TrieLevel();
l = n.Next;
if (isLast) break;
start = end + 1;
}
if (n == null)
throw new ArgumentException(ErrInvalidSubject.Message);
n.Subs[value] = subject;
_count++;
}
// -------------------------------------------------------------------------
// Private: Remove core (lock must be held by caller)
// -------------------------------------------------------------------------
private void RemoveCore(string subject, T value)
{
var sfwc = false;
var l = _root;
// We use a fixed-size stack-style array to track visited (level, node, token)
// triples so we can prune upward after removal. 32 is the same as Go's [32]lnt.
var levels = new LevelNodeToken[32];
var levelCount = 0;
TrieNode? n = null;
var start = 0;
while (start <= subject.Length)
{
var end = subject.IndexOf(Btsep, start);
var isLast = end < 0;
if (isLast) end = subject.Length;
var tokenLen = end - start;
if (tokenLen == 0 || sfwc)
throw new ArgumentException(ErrInvalidSubject.Message);
if (l == null!)
throw new KeyNotFoundException(ErrNotFound.Message);
var tokenStr = subject.Substring(start, tokenLen);
if (tokenLen > 1)
{
l.Nodes.TryGetValue(tokenStr, out n);
}
else
{
switch (tokenStr[0])
{
case Pwc:
n = l.PwcNode;
break;
case Fwc:
n = l.FwcNode;
sfwc = true;
break;
default:
l.Nodes.TryGetValue(tokenStr, out n);
break;
}
}
if (n != null)
{
if (levelCount < levels.Length)
levels[levelCount++] = new LevelNodeToken(l, n, tokenStr);
l = n.Next!;
}
else
{
l = null!;
}
if (isLast) break;
start = end + 1;
}
// Remove from the final node's subscription map.
if (!RemoveFromNode(n, value))
throw new KeyNotFoundException(ErrNotFound.Message);
_count--;
// Prune empty nodes upward.
for (var i = levelCount - 1; i >= 0; i--)
{
var (lv, nd, tk) = levels[i];
if (nd.IsEmpty())
lv.PruneNode(nd, tk);
}
}
private static bool RemoveFromNode(TrieNode? n, T value)
{
if (n == null) return false;
return n.Subs.Remove(value);
}
// -------------------------------------------------------------------------
// Private: matchLevel - recursive trie descent with callback
// Mirrors Go's matchLevel function exactly.
// -------------------------------------------------------------------------
private static void MatchLevel(TrieLevel? l, string[] tokens, int start, Action<T> action)
{
TrieNode? pwc = null;
TrieNode? n = null;
for (var i = start; i < tokens.Length; i++)
{
if (l == null) return;
// Full-wildcard at this level matches everything at/below.
if (l.FwcNode != null)
CallbacksForResults(l.FwcNode, action);
pwc = l.PwcNode;
if (pwc != null)
MatchLevel(pwc.Next, tokens, i + 1, action);
l.Nodes.TryGetValue(tokens[i], out n);
l = n?.Next;
}
// After consuming all tokens, emit subs from exact and pwc matches.
if (n != null)
CallbacksForResults(n, action);
if (pwc != null)
CallbacksForResults(pwc, action);
}
private static void CallbacksForResults(TrieNode n, Action<T> action)
{
foreach (var sub in n.Subs.Keys)
action(sub);
}
// -------------------------------------------------------------------------
// Private: matchLevelForAny - returns true on first match, counting via np
// Mirrors Go's matchLevelForAny function exactly.
// -------------------------------------------------------------------------
private static bool MatchLevelForAny(TrieLevel? l, string[] tokens, int start, ref int np)
{
TrieNode? pwc = null;
TrieNode? n = null;
for (var i = start; i < tokens.Length; i++)
{
if (l == null) return false;
if (l.FwcNode != null)
{
np += l.FwcNode.Subs.Count;
return true;
}
pwc = l.PwcNode;
if (pwc != null)
{
if (MatchLevelForAny(pwc.Next, tokens, i + 1, ref np))
return true;
}
l.Nodes.TryGetValue(tokens[i], out n);
l = n?.Next;
}
if (n != null)
{
np += n.Subs.Count;
if (n.Subs.Count > 0) return true;
}
if (pwc != null)
{
np += pwc.Subs.Count;
return pwc.Subs.Count > 0;
}
return false;
}
// -------------------------------------------------------------------------
// Private: hasInterestStartingIn - mirrors Go's hasInterestStartingIn
// -------------------------------------------------------------------------
private static bool HasInterestStartingInLevel(TrieLevel? l, string[] tokens, int start)
{
if (l == null) return false;
if (start >= tokens.Length) return true;
if (l.FwcNode != null) return true;
var found = false;
if (l.PwcNode != null)
found = HasInterestStartingInLevel(l.PwcNode.Next, tokens, start + 1);
if (!found && l.Nodes.TryGetValue(tokens[start], out var n))
found = HasInterestStartingInLevel(n.Next, tokens, start + 1);
return found;
}
// -------------------------------------------------------------------------
// Private: numLevels helper - mirrors Go's visitLevel
// -------------------------------------------------------------------------
private static int VisitLevel(TrieLevel? l, int depth)
{
if (l == null || l.NumNodes() == 0) return depth;
depth++;
var maxDepth = depth;
foreach (var n in l.Nodes.Values)
{
var d = VisitLevel(n.Next, depth);
if (d > maxDepth) maxDepth = d;
}
if (l.PwcNode != null)
{
var d = VisitLevel(l.PwcNode.Next, depth);
if (d > maxDepth) maxDepth = d;
}
if (l.FwcNode != null)
{
var d = VisitLevel(l.FwcNode.Next, depth);
if (d > maxDepth) maxDepth = d;
}
return maxDepth;
}
// -------------------------------------------------------------------------
// Private: tokenization helpers
// -------------------------------------------------------------------------
/// <summary>
/// Tokenizes a subject for match/hasInterest operations.
/// Returns <see langword="null"/> if the subject contains an empty token,
/// because an empty token can never match any subscription in the trie.
/// Mirrors Go's inline tokenization in <c>match()</c> and <c>hasInterest()</c>.
/// </summary>
private static string[]? TokenizeForMatch(string subject)
{
if (subject.Length == 0) return null;
var tokens = new List<string>(8);
var start = 0;
for (var i = 0; i < subject.Length; i++)
{
if (subject[i] == Btsep)
{
if (i - start == 0) return null; // empty token
tokens.Add(subject.Substring(start, i - start));
start = i + 1;
}
}
// Trailing separator produces empty last token.
if (start >= subject.Length) return null;
tokens.Add(subject.Substring(start));
return tokens.ToArray();
}
/// <summary>
/// Tokenizes a subject into a string array without validation.
/// Mirrors Go's <c>tokenizeSubjectIntoSlice</c>.
/// </summary>
private static string[] TokenizeSubjectIntoSlice(string subject)
{
var tokens = new List<string>(8);
var start = 0;
for (var i = 0; i < subject.Length; i++)
{
if (subject[i] == Btsep)
{
tokens.Add(subject.Substring(start, i - start));
start = i + 1;
}
}
tokens.Add(subject.Substring(start));
return tokens.ToArray();
}
// -------------------------------------------------------------------------
// Private: Trie node and level types
// -------------------------------------------------------------------------
/// <summary>
/// A trie node holding a subscription map and an optional link to the next level.
/// Mirrors Go's <c>node[T]</c>.
/// </summary>
private sealed class TrieNode
{
/// <summary>Maps subscription value → original subject string.</summary>
public readonly Dictionary<T, string> Subs = new();
/// <summary>The next trie level below this node, or null if at a leaf.</summary>
public TrieLevel? Next;
/// <summary>
/// Returns true when the node has no subscriptions and no live children.
/// Used during removal to decide whether to prune this node.
/// Mirrors Go's <c>node.isEmpty()</c>.
/// </summary>
public bool IsEmpty() => Subs.Count == 0 && (Next == null || Next.NumNodes() == 0);
}
/// <summary>
/// A trie level containing named child nodes and special wildcard slots.
/// Mirrors Go's <c>level[T]</c>.
/// </summary>
private sealed class TrieLevel
{
public readonly Dictionary<string, TrieNode> Nodes = new();
public TrieNode? PwcNode; // '*' single-token wildcard node
public TrieNode? FwcNode; // '>' full-wildcard node
/// <summary>
/// Returns the total count of live nodes at this level.
/// Mirrors Go's <c>level.numNodes()</c>.
/// </summary>
public int NumNodes()
{
var num = Nodes.Count;
if (PwcNode != null) num++;
if (FwcNode != null) num++;
return num;
}
/// <summary>
/// Removes an empty node from this level, using reference equality to
/// distinguish wildcard slots from named slots.
/// Mirrors Go's <c>level.pruneNode()</c>.
/// </summary>
public void PruneNode(TrieNode n, string token)
{
if (ReferenceEquals(n, FwcNode))
FwcNode = null;
else if (ReferenceEquals(n, PwcNode))
PwcNode = null;
else
Nodes.Remove(token);
}
}
/// <summary>
/// Tracks a (level, node, token) triple during removal for upward pruning.
/// Mirrors Go's <c>lnt[T]</c>.
/// </summary>
private readonly struct LevelNodeToken
{
public readonly TrieLevel Level;
public readonly TrieNode Node;
public readonly string Token;
public LevelNodeToken(TrieLevel level, TrieNode node, string token)
{
Level = level;
Node = node;
Token = token;
}
public void Deconstruct(out TrieLevel level, out TrieNode node, out string token)
{
level = Level;
node = Node;
token = Token;
}
}
}
/// <summary>
/// A lightweight sublist that tracks interest only, without storing any associated data.
/// Equivalent to Go's <c>SimpleSublist = GenericSublist[struct{}]</c>.
/// </summary>
public sealed class SimpleSublist : GenericSublist<EmptyStruct>
{
internal SimpleSublist() { }
}