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.
679 lines
22 KiB
C#
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>></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() { }
|
|
}
|