Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
@@ -26,11 +26,77 @@ public sealed class SubList : IDisposable
|
||||
private ulong _inserts;
|
||||
private ulong _removes;
|
||||
private int _highFanoutNodes;
|
||||
private Action<bool>? _interestStateNotification;
|
||||
private readonly Dictionary<string, List<Action<bool>>> _queueInsertNotifications = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<Action<bool>>> _queueRemoveNotifications = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly record struct CachedResult(SubListResult Result, long Generation);
|
||||
internal readonly record struct RoutedSubKeyInfo(string RouteId, string Account, string Subject, string? Queue);
|
||||
|
||||
public event Action<InterestChange>? InterestChanged;
|
||||
|
||||
public SubList()
|
||||
: this(enableCache: true)
|
||||
{
|
||||
}
|
||||
|
||||
public SubList(bool enableCache)
|
||||
{
|
||||
if (!enableCache)
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
public static SubList NewSublistNoCache() => new(enableCache: false);
|
||||
|
||||
public bool CacheEnabled() => _cache != null;
|
||||
|
||||
public void RegisterNotification(Action<bool> callback) => _interestStateNotification = callback;
|
||||
|
||||
public void ClearNotification() => _interestStateNotification = null;
|
||||
|
||||
public bool RegisterQueueNotification(string subject, string queue, Action<bool> callback)
|
||||
{
|
||||
if (callback == null || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(queue))
|
||||
return false;
|
||||
if (SubjectMatch.SubjectHasWildcard(subject) || SubjectMatch.SubjectHasWildcard(queue))
|
||||
return false;
|
||||
|
||||
bool hasInterest;
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
hasInterest = HasExactQueueInterestNoLock(subject, queue);
|
||||
var map = hasInterest ? _queueRemoveNotifications : _queueInsertNotifications;
|
||||
if (!AddQueueNotifyNoLock(map, key, callback))
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
callback(hasInterest);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ClearQueueNotification(string subject, string queue, Action<bool> callback)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var removed = RemoveQueueNotifyNoLock(_queueRemoveNotifications, key, callback);
|
||||
removed |= RemoveQueueNotifyNoLock(_queueInsertNotifications, key, callback);
|
||||
return removed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
@@ -112,7 +178,7 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var key = $"{sub.RouteId}|{sub.Account}|{sub.Subject}|{sub.Queue}";
|
||||
var key = BuildRoutedSubKey(sub.RouteId, sub.Account, sub.Subject, sub.Queue);
|
||||
var changed = false;
|
||||
if (sub.IsRemoval)
|
||||
{
|
||||
@@ -149,6 +215,127 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateRemoteQSub(RemoteSubscription sub)
|
||||
{
|
||||
if (sub.Queue == null)
|
||||
return;
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var key = BuildRoutedSubKey(sub.RouteId, sub.Account, sub.Subject, sub.Queue);
|
||||
if (!_remoteSubs.TryGetValue(key, out var existing))
|
||||
return;
|
||||
|
||||
var nextWeight = Math.Max(1, sub.QueueWeight);
|
||||
if (existing.QueueWeight == nextWeight)
|
||||
return;
|
||||
|
||||
_remoteSubs[key] = existing with { QueueWeight = nextWeight };
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildRoutedSubKey(string routeId, string account, string subject, string? queue)
|
||||
=> $"{routeId}|{account}|{subject}|{queue}";
|
||||
|
||||
internal static string? GetAccNameFromRoutedSubKey(string routedSubKey)
|
||||
=> GetRoutedSubKeyInfo(routedSubKey)?.Account;
|
||||
|
||||
internal static RoutedSubKeyInfo? GetRoutedSubKeyInfo(string routedSubKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(routedSubKey))
|
||||
return null;
|
||||
|
||||
var parts = routedSubKey.Split('|');
|
||||
if (parts.Length != 4)
|
||||
return null;
|
||||
|
||||
if (parts[0].Length == 0 || parts[1].Length == 0 || parts[2].Length == 0)
|
||||
return null;
|
||||
|
||||
var queue = parts[3].Length == 0 ? null : parts[3];
|
||||
return new RoutedSubKeyInfo(parts[0], parts[1], parts[2], queue);
|
||||
}
|
||||
|
||||
public int RemoveRemoteSubs(string routeId)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var kvp in _remoteSubs.ToArray())
|
||||
{
|
||||
var info = GetRoutedSubKeyInfo(kvp.Key);
|
||||
if (info == null || !string.Equals(info.Value.RouteId, routeId, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (_remoteSubs.Remove(kvp.Key))
|
||||
{
|
||||
removed++;
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.RemoteRemoved,
|
||||
kvp.Value.Subject,
|
||||
kvp.Value.Queue,
|
||||
kvp.Value.Account));
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0)
|
||||
Interlocked.Increment(ref _generation);
|
||||
|
||||
return removed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public int RemoveRemoteSubsForAccount(string routeId, string account)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var kvp in _remoteSubs.ToArray())
|
||||
{
|
||||
var info = GetRoutedSubKeyInfo(kvp.Key);
|
||||
if (info == null)
|
||||
continue;
|
||||
|
||||
if (!string.Equals(info.Value.RouteId, routeId, StringComparison.Ordinal)
|
||||
|| !string.Equals(info.Value.Account, account, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_remoteSubs.Remove(kvp.Key))
|
||||
{
|
||||
removed++;
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.RemoteRemoved,
|
||||
kvp.Value.Subject,
|
||||
kvp.Value.Queue,
|
||||
kvp.Value.Account));
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0)
|
||||
Interlocked.Increment(ref _generation);
|
||||
|
||||
return removed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasRemoteInterest(string subject)
|
||||
=> HasRemoteInterest("$G", subject);
|
||||
|
||||
@@ -183,6 +370,7 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var hadInterest = _count > 0;
|
||||
var level = _root;
|
||||
TrieNode? node = null;
|
||||
bool sawFwc = false;
|
||||
@@ -240,6 +428,10 @@ public sealed class SubList : IDisposable
|
||||
_count++;
|
||||
_inserts++;
|
||||
Interlocked.Increment(ref _generation);
|
||||
if (sub.Queue != null && _queueInsertNotifications.Count > 0)
|
||||
CheckForQueueInsertNotificationNoLock(sub.Subject, sub.Queue);
|
||||
if (!hadInterest && _count > 0)
|
||||
_interestStateNotification?.Invoke(true);
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.LocalAdded,
|
||||
sub.Subject,
|
||||
@@ -258,10 +450,15 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var hadInterest = _count > 0;
|
||||
if (RemoveInternal(sub))
|
||||
{
|
||||
_removes++;
|
||||
Interlocked.Increment(ref _generation);
|
||||
if (sub.Queue != null && _queueRemoveNotifications.Count > 0)
|
||||
CheckForQueueRemoveNotificationNoLock(sub.Subject, sub.Queue);
|
||||
if (hadInterest && _count == 0)
|
||||
_interestStateNotification?.Invoke(false);
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.LocalRemoved,
|
||||
sub.Subject,
|
||||
@@ -455,6 +652,90 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string QueueNotifyKey(string subject, string queue) => $"{subject} {queue}";
|
||||
|
||||
private static bool AddQueueNotifyNoLock(Dictionary<string, List<Action<bool>>> map, string key, Action<bool> callback)
|
||||
{
|
||||
if (!map.TryGetValue(key, out var callbacks))
|
||||
{
|
||||
callbacks = [];
|
||||
map[key] = callbacks;
|
||||
}
|
||||
else if (callbacks.Contains(callback))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
callbacks.Add(callback);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RemoveQueueNotifyNoLock(Dictionary<string, List<Action<bool>>> map, string key, Action<bool> callback)
|
||||
{
|
||||
if (!map.TryGetValue(key, out var callbacks))
|
||||
return false;
|
||||
|
||||
var removed = callbacks.Remove(callback);
|
||||
if (callbacks.Count == 0)
|
||||
map.Remove(key);
|
||||
return removed;
|
||||
}
|
||||
|
||||
private bool HasExactQueueInterestNoLock(string subject, string queue)
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
CollectAll(_root, subs);
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
if (sub.Queue != null
|
||||
&& string.Equals(sub.Subject, subject, StringComparison.Ordinal)
|
||||
&& string.Equals(sub.Queue, queue, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void CheckForQueueInsertNotificationNoLock(string subject, string queue)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
if (!_queueInsertNotifications.TryGetValue(key, out var callbacks) || callbacks.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var callback in callbacks)
|
||||
callback(true);
|
||||
|
||||
if (!_queueRemoveNotifications.TryGetValue(key, out var removeCallbacks))
|
||||
{
|
||||
removeCallbacks = [];
|
||||
_queueRemoveNotifications[key] = removeCallbacks;
|
||||
}
|
||||
removeCallbacks.AddRange(callbacks);
|
||||
_queueInsertNotifications.Remove(key);
|
||||
}
|
||||
|
||||
private void CheckForQueueRemoveNotificationNoLock(string subject, string queue)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
if (!_queueRemoveNotifications.TryGetValue(key, out var callbacks) || callbacks.Count == 0)
|
||||
return;
|
||||
if (HasExactQueueInterestNoLock(subject, queue))
|
||||
return;
|
||||
|
||||
foreach (var callback in callbacks)
|
||||
callback(false);
|
||||
|
||||
if (!_queueInsertNotifications.TryGetValue(key, out var insertCallbacks))
|
||||
{
|
||||
insertCallbacks = [];
|
||||
_queueInsertNotifications[key] = insertCallbacks;
|
||||
}
|
||||
insertCallbacks.AddRange(callbacks);
|
||||
_queueRemoveNotifications.Remove(key);
|
||||
}
|
||||
|
||||
private void SweepCache()
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
@@ -632,6 +913,9 @@ public sealed class SubList : IDisposable
|
||||
CacheHitRate = hitRate,
|
||||
MaxFanout = maxFanout,
|
||||
AvgFanout = cacheEntries > 0 ? (double)totalFanout / cacheEntries : 0.0,
|
||||
TotalFanout = (int)totalFanout,
|
||||
CacheEntries = cacheEntries,
|
||||
CacheHits = cacheHits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -695,7 +979,11 @@ public sealed class SubList : IDisposable
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
if (RemoveInternal(sub))
|
||||
{
|
||||
_removes++;
|
||||
if (sub.Queue != null && _queueRemoveNotifications.Count > 0)
|
||||
CheckForQueueRemoveNotificationNoLock(sub.Subject, sub.Queue);
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _generation);
|
||||
@@ -724,6 +1012,34 @@ public sealed class SubList : IDisposable
|
||||
return subs;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Subscription> LocalSubs(bool includeLeafHubs = false)
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
CollectLocalSubs(_root, subs, includeLeafHubs);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
|
||||
internal int NumLevels()
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return VisitLevel(_root, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public SubListResult ReverseMatch(string subject)
|
||||
{
|
||||
var tokens = Tokenize(subject);
|
||||
@@ -857,6 +1173,82 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectLocalSubs(TrieLevel level, List<Subscription> subs, bool includeLeafHubs)
|
||||
{
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
AddNodeLocalSubs(node, subs, includeLeafHubs);
|
||||
if (node.Next != null)
|
||||
CollectLocalSubs(node.Next, subs, includeLeafHubs);
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
AddNodeLocalSubs(level.Pwc, subs, includeLeafHubs);
|
||||
if (level.Pwc.Next != null)
|
||||
CollectLocalSubs(level.Pwc.Next, subs, includeLeafHubs);
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
AddNodeLocalSubs(level.Fwc, subs, includeLeafHubs);
|
||||
if (level.Fwc.Next != null)
|
||||
CollectLocalSubs(level.Fwc.Next, subs, includeLeafHubs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddNodeLocalSubs(TrieNode node, List<Subscription> subs, bool includeLeafHubs)
|
||||
{
|
||||
foreach (var sub in node.PlainSubs)
|
||||
AddLocalSub(sub, subs, includeLeafHubs);
|
||||
foreach (var (_, qset) in node.QueueSubs)
|
||||
foreach (var sub in qset)
|
||||
AddLocalSub(sub, subs, includeLeafHubs);
|
||||
}
|
||||
|
||||
private static void AddLocalSub(Subscription sub, List<Subscription> subs, bool includeLeafHubs)
|
||||
{
|
||||
if (sub.Client == null)
|
||||
return;
|
||||
|
||||
var kind = sub.Client.Kind;
|
||||
if (kind is global::NATS.Server.ClientKind.Client
|
||||
or global::NATS.Server.ClientKind.System
|
||||
or global::NATS.Server.ClientKind.JetStream
|
||||
or global::NATS.Server.ClientKind.Account
|
||||
|| (includeLeafHubs && kind == global::NATS.Server.ClientKind.Leaf))
|
||||
{
|
||||
subs.Add(sub);
|
||||
}
|
||||
}
|
||||
|
||||
private static int VisitLevel(TrieLevel? level, int depth)
|
||||
{
|
||||
if (level == null || (level.Nodes.Count == 0 && level.Pwc == null && level.Fwc == null))
|
||||
return depth;
|
||||
|
||||
depth++;
|
||||
var maxDepth = depth;
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
var childDepth = VisitLevel(node.Next, depth);
|
||||
if (childDepth > maxDepth)
|
||||
maxDepth = childDepth;
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
var pwcDepth = VisitLevel(level.Pwc.Next, depth);
|
||||
if (pwcDepth > maxDepth)
|
||||
maxDepth = pwcDepth;
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
var fwcDepth = VisitLevel(level.Fwc.Next, depth);
|
||||
if (fwcDepth > maxDepth)
|
||||
maxDepth = fwcDepth;
|
||||
}
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
private static void ReverseMatchLevel(TrieLevel? level, string[] tokens, int tokenIndex,
|
||||
List<Subscription> plainSubs, List<List<Subscription>> queueSubs)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user