6.9 KiB
SubList
SubList is the subscription routing trie for the core server. Every publish path calls Match() to find the local plain subscribers and queue groups interested in a subject. The type also tracks remote route and gateway interest so clustering code can answer HasRemoteInterest(...) and MatchRemote(...) queries without a second routing structure.
Go reference: golang/nats-server/server/sublist.go
Thread Safety
SubList uses a single ReaderWriterLockSlim (_lock) to protect trie mutation, remote-interest bookkeeping, and cache state.
| Operation | Lock |
|---|---|
Cache hit in Match() |
Read lock |
Cache miss in Match() |
Write lock |
Insert() / Remove() / RemoveBatch() |
Write lock |
Remote-interest mutation (ApplyRemoteSub, UpdateRemoteQSub, cleanup) |
Write lock |
Read-only queries (Count, HasRemoteInterest, MatchRemote, Stats) |
Read lock |
Match() uses generation-based double-checked locking. It first checks the cache under a read lock, then retries under the write lock before traversing the trie and updating the cache.
Trie Structure
The trie is built from TrieLevel and TrieNode:
private sealed class TrieLevel
{
public readonly Dictionary<string, TrieNode> Nodes = new(StringComparer.Ordinal);
public TrieNode? Pwc;
public TrieNode? Fwc;
}
private sealed class TrieNode
{
public TrieLevel? Next;
public readonly HashSet<Subscription> PlainSubs = [];
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
public bool PackedListEnabled;
}
Nodesstores literal-token edges by exact token string.Pwcstores the*edge for the current token position.Fwcstores the>edge for the current token position.PlainSubsstores non-queue subscriptions attached to the terminal node.QueueSubsgroups queue subscriptions by queue name at the terminal node.
The root of the trie is _root, a TrieLevel with no parent node.
Token Traversal
TokenEnumerator walks a subject string token-by-token using ReadOnlySpan<char> slices, so traversal itself does not allocate. Literal-token insert and remove paths use TryGetLiteralNode(...) plus SubjectMatch.TokenEquals(...) to reuse the existing trie key string when the token is already present, instead of calling token.ToString() on every hop.
That keeps literal-subject maintenance allocation-lean while preserving the current Dictionary<string, TrieNode> storage model.
Local Subscription Operations
Insert
Insert(Subscription sub) walks the trie one token at a time:
*follows or createsPwc>follows or createsFwcand terminates further token traversal- literal tokens follow or create
Nodes[token]
The terminal node stores the subscription in either PlainSubs or the appropriate queue-group bucket in QueueSubs. Every successful insert increments _generation, which invalidates cached match results.
Remove
Remove(Subscription sub) and RemoveBatch(IEnumerable<Subscription>) walk the same subject path, remove the subscription from the terminal node, and then prune empty trie nodes on the way back out. Removing a subscription also bumps _generation, so any stale cached result is ignored on the next lookup.
Remote Interest Bookkeeping
Remote route and gateway subscriptions are stored separately from the local trie in _remoteSubs:
private readonly Dictionary<RoutedSubKey, RemoteSubscription> _remoteSubs = [];
RoutedSubKey is a compact value key:
internal readonly record struct RoutedSubKey(
string RouteId,
string Account,
string Subject,
string? Queue);
This replaces the earlier "route|account|subject|queue" composite string model. The change removes repeated string concatenation, Split('|'), and runtime reparsing in remote cleanup paths.
Remote-interest APIs:
ApplyRemoteSub(...)inserts or removes aRemoteSubscriptionUpdateRemoteQSub(...)updates queue weight for an existing remote queue subscriptionRemoveRemoteSubs(routeId)removes all remote interest for a disconnected routeRemoveRemoteSubsForAccount(routeId, account)removes only one route/account sliceHasRemoteInterest(account, subject)answers whether any remote subscription matchesMatchRemote(account, subject)returns the expanded weighted remote matches
Cleanup paths collect matching RoutedSubKey values into a reusable per-thread list and then remove them, avoiding _remoteSubs.ToArray() snapshots on every sweep.
Match Pipeline
Match(string subject) is the hot path.
- Increment
_matches - Read the current
_generation - Try the cache under a read lock
- On cache miss, tokenize the subject and retry under the write lock
- Traverse the trie and build a
SubListResult - Cache the result with the generation that produced it
Cached entries are stored as:
private readonly record struct CachedResult(SubListResult Result, long Generation);
A cache entry is valid only if its stored generation matches the current _generation. Any local or remote-interest mutation increments _generation, so stale entries are ignored automatically.
Match Builder
Cache misses use a reusable per-thread MatchBuilder instead of allocating fresh nested List<List<Subscription>> structures on every traversal. The builder:
- reuses a
List<Subscription>for plain subscribers - reuses queue-group lists across matches
- merges queue matches by queue name during traversal
- materializes the public
SubListResultarrays only once at the end
This keeps the public contract unchanged while removing temporary match-building churn from the publish path.
Intentional Remaining Allocations
The current implementation still allocates in two places by design:
- the tokenized
string[]produced byTokenize(subject)on cache misses - the final
Subscription[]andSubscription[][]arrays stored inSubListResult
Those allocations are part of the current public result shape and cache model.
Cache Strategy
The cache is a Dictionary<string, CachedResult> keyed by literal publish subject with StringComparer.Ordinal.
CacheMax = 1024CacheSweep = 256
When the cache grows past CacheMax, SubListCacheSweeper schedules a sweep that removes enough keys to return to the target size. The sweep is intentionally simple; it is not LRU.
The cache stores fully materialized SubListResult instances because publish callers need stable array-based results immediately after lookup.
Statistics and Monitoring
Stats() exposes:
- subscription count
- cache entry count
- insert/remove/match counts
- cache hit rate
- fanout statistics derived from cached results
These counters are used by tests and monitoring code to validate routing behavior and cache effectiveness.