- Rename tests/NATS.Server.Tests -> tests/NATS.Server.Core.Tests - Update solution file, InternalsVisibleTo, and csproj references - Remove JETSTREAM_INTEGRATION_MATRIX and NATS.NKeys from csproj (moved to JetStream.Tests and Auth.Tests) - Update all namespaces from NATS.Server.Tests.* to NATS.Server.Core.Tests.* - Replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls - Fix stale namespace in Transport.Tests/NetworkingGoParityTests.cs
550 lines
18 KiB
C#
550 lines
18 KiB
C#
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Core.Tests;
|
|
|
|
public class SubListTests
|
|
{
|
|
private static Subscription MakeSub(string subject, string? queue = null, string sid = "1")
|
|
=> new() { Subject = subject, Queue = queue, Sid = sid };
|
|
|
|
[Fact]
|
|
public void Insert_and_match_literal_subject()
|
|
{
|
|
var sl = new SubList();
|
|
var sub = MakeSub("foo.bar");
|
|
sl.Insert(sub);
|
|
|
|
var r = sl.Match("foo.bar");
|
|
r.PlainSubs.ShouldHaveSingleItem();
|
|
r.PlainSubs[0].ShouldBeSameAs(sub);
|
|
r.QueueSubs.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Match_returns_empty_for_no_match()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar"));
|
|
|
|
var r = sl.Match("foo.baz");
|
|
r.PlainSubs.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Match_partial_wildcard()
|
|
{
|
|
var sl = new SubList();
|
|
var sub = MakeSub("foo.*");
|
|
sl.Insert(sub);
|
|
|
|
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
|
sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem();
|
|
sl.Match("foo.bar.baz").PlainSubs.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Match_full_wildcard()
|
|
{
|
|
var sl = new SubList();
|
|
var sub = MakeSub("foo.>");
|
|
sl.Insert(sub);
|
|
|
|
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
|
sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
|
|
sl.Match("foo").PlainSubs.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Match_root_full_wildcard()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub(">"));
|
|
|
|
sl.Match("foo").PlainSubs.ShouldHaveSingleItem();
|
|
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
|
sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
|
|
}
|
|
|
|
[Fact]
|
|
public void Match_collects_multiple_subs()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
|
sl.Insert(MakeSub("foo.*", sid: "2"));
|
|
sl.Insert(MakeSub("foo.>", sid: "3"));
|
|
sl.Insert(MakeSub(">", sid: "4"));
|
|
|
|
var r = sl.Match("foo.bar");
|
|
r.PlainSubs.Length.ShouldBe(4);
|
|
}
|
|
|
|
[Fact]
|
|
public void Remove_subscription()
|
|
{
|
|
var sl = new SubList();
|
|
var sub = MakeSub("foo.bar");
|
|
sl.Insert(sub);
|
|
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
|
|
|
sl.Remove(sub);
|
|
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Queue_group_subscriptions()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar", queue: "workers", sid: "1"));
|
|
sl.Insert(MakeSub("foo.bar", queue: "workers", sid: "2"));
|
|
sl.Insert(MakeSub("foo.bar", queue: "loggers", sid: "3"));
|
|
|
|
var r = sl.Match("foo.bar");
|
|
r.PlainSubs.ShouldBeEmpty();
|
|
r.QueueSubs.Length.ShouldBe(2); // 2 queue groups
|
|
}
|
|
|
|
[Fact]
|
|
public void Count_tracks_subscriptions()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Count.ShouldBe(0u);
|
|
|
|
sl.Insert(MakeSub("foo", sid: "1"));
|
|
sl.Insert(MakeSub("bar", sid: "2"));
|
|
sl.Count.ShouldBe(2u);
|
|
|
|
sl.Remove(MakeSub("foo", sid: "1"));
|
|
// Remove by reference won't work — we need the same instance
|
|
}
|
|
|
|
[Fact]
|
|
public void Count_tracks_with_same_instance()
|
|
{
|
|
var sl = new SubList();
|
|
var sub = MakeSub("foo");
|
|
sl.Insert(sub);
|
|
sl.Count.ShouldBe(1u);
|
|
sl.Remove(sub);
|
|
sl.Count.ShouldBe(0u);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cache_invalidation_on_insert()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
|
|
|
// Prime the cache
|
|
var r1 = sl.Match("foo.bar");
|
|
r1.PlainSubs.ShouldHaveSingleItem();
|
|
|
|
// Insert a wildcard that matches — cache should be invalidated
|
|
sl.Insert(MakeSub("foo.*", sid: "2"));
|
|
|
|
var r2 = sl.Match("foo.bar");
|
|
r2.PlainSubs.Length.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Match_partial_wildcard_at_different_levels()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("*.bar.baz", sid: "1"));
|
|
sl.Insert(MakeSub("foo.*.baz", sid: "2"));
|
|
sl.Insert(MakeSub("foo.bar.*", sid: "3"));
|
|
|
|
var r = sl.Match("foo.bar.baz");
|
|
r.PlainSubs.Length.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stats_returns_correct_values()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
|
sl.Insert(MakeSub("foo.baz", sid: "2"));
|
|
sl.Match("foo.bar");
|
|
sl.Match("foo.bar"); // cache hit
|
|
|
|
var stats = sl.Stats();
|
|
stats.NumSubs.ShouldBe(2u);
|
|
stats.NumInserts.ShouldBe(2ul);
|
|
stats.NumMatches.ShouldBe(2ul);
|
|
stats.CacheHitRate.ShouldBeGreaterThan(0.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void HasInterest_returns_true_when_subscribers_exist()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar"));
|
|
sl.HasInterest("foo.bar").ShouldBeTrue();
|
|
sl.HasInterest("foo.baz").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void HasInterest_with_wildcards()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.*"));
|
|
sl.HasInterest("foo.bar").ShouldBeTrue();
|
|
sl.HasInterest("bar.baz").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void NumInterest_counts_subscribers()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
|
sl.Insert(MakeSub("foo.*", sid: "2"));
|
|
sl.Insert(MakeSub("foo.bar", queue: "q1", sid: "3"));
|
|
|
|
var (np, nq) = sl.NumInterest("foo.bar");
|
|
np.ShouldBe(2); // foo.bar + foo.*
|
|
nq.ShouldBe(1); // queue sub
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveBatch_removes_all()
|
|
{
|
|
var sl = new SubList();
|
|
var sub1 = MakeSub("foo.bar", sid: "1");
|
|
var sub2 = MakeSub("foo.baz", sid: "2");
|
|
var sub3 = MakeSub("bar.qux", sid: "3");
|
|
sl.Insert(sub1);
|
|
sl.Insert(sub2);
|
|
sl.Insert(sub3);
|
|
sl.Count.ShouldBe(3u);
|
|
|
|
sl.RemoveBatch([sub1, sub2]);
|
|
sl.Count.ShouldBe(1u);
|
|
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
|
sl.Match("bar.qux").PlainSubs.ShouldHaveSingleItem();
|
|
}
|
|
|
|
[Fact]
|
|
public void All_returns_every_subscription()
|
|
{
|
|
var sl = new SubList();
|
|
var sub1 = MakeSub("foo.bar", sid: "1");
|
|
var sub2 = MakeSub("foo.*", sid: "2");
|
|
var sub3 = MakeSub("bar.>", queue: "q", sid: "3");
|
|
sl.Insert(sub1);
|
|
sl.Insert(sub2);
|
|
sl.Insert(sub3);
|
|
|
|
var all = sl.All();
|
|
all.Count.ShouldBe(3);
|
|
all.ShouldContain(sub1);
|
|
all.ShouldContain(sub2);
|
|
all.ShouldContain(sub3);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReverseMatch_finds_patterns_matching_literal()
|
|
{
|
|
var sl = new SubList();
|
|
var sub1 = MakeSub("foo.bar", sid: "1");
|
|
var sub2 = MakeSub("foo.*", sid: "2");
|
|
var sub3 = MakeSub("foo.>", sid: "3");
|
|
var sub4 = MakeSub("bar.baz", sid: "4");
|
|
sl.Insert(sub1);
|
|
sl.Insert(sub2);
|
|
sl.Insert(sub3);
|
|
sl.Insert(sub4);
|
|
|
|
var result = sl.ReverseMatch("foo.bar");
|
|
result.PlainSubs.Length.ShouldBe(3); // foo.bar, foo.*, foo.>
|
|
result.PlainSubs.ShouldContain(sub1);
|
|
result.PlainSubs.ShouldContain(sub2);
|
|
result.PlainSubs.ShouldContain(sub3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generation_ID_invalidates_cache()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
|
|
|
// Prime cache
|
|
var r1 = sl.Match("foo.bar");
|
|
r1.PlainSubs.Length.ShouldBe(1);
|
|
|
|
// Insert another sub (bumps generation)
|
|
sl.Insert(MakeSub("foo.bar", sid: "2"));
|
|
|
|
// Cache should be invalidated by generation mismatch
|
|
var r2 = sl.Match("foo.bar");
|
|
r2.PlainSubs.Length.ShouldBe(2);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Concurrency and edge case tests
|
|
// Ported from: golang/nats-server/server/sublist_test.go
|
|
// TestSublistRaceOnRemove, TestSublistRaceOnInsert, TestSublistRaceOnMatch,
|
|
// TestSublistRemoveWithLargeSubs, TestSublistInvalidSubjectsInsert,
|
|
// TestSublistInsertWithWildcardsAsLiterals
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Verifies that removing subscriptions concurrently while reading cached
|
|
/// match results does not corrupt the subscription data. Reads the cached
|
|
/// result before removals begin and iterates queue entries while removals
|
|
/// run in parallel.
|
|
/// Ref: testSublistRaceOnRemove (sublist_test.go:823)
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Race_on_remove_does_not_corrupt_cache()
|
|
{
|
|
var sl = new SubList();
|
|
const int total = 100;
|
|
var subs = new Subscription[total];
|
|
|
|
for (int i = 0; i < total; i++)
|
|
{
|
|
subs[i] = new Subscription { Subject = "foo", Queue = "bar", Sid = i.ToString() };
|
|
sl.Insert(subs[i]);
|
|
}
|
|
|
|
// Prime cache with one warm-up call then capture result
|
|
sl.Match("foo");
|
|
var cached = sl.Match("foo");
|
|
|
|
// Start removing all subs concurrently while we inspect the cached result
|
|
var removeTask = Task.Run(() =>
|
|
{
|
|
foreach (var sub in subs)
|
|
sl.Remove(sub);
|
|
});
|
|
|
|
// Iterate all queue groups in the cached snapshot — must not throw
|
|
foreach (var qgroup in cached.QueueSubs)
|
|
{
|
|
foreach (var sub in qgroup)
|
|
{
|
|
sub.Queue.ShouldBe("bar");
|
|
}
|
|
}
|
|
|
|
await removeTask;
|
|
|
|
// After all removals, no interest should remain
|
|
var afterRemoval = sl.Match("foo");
|
|
afterRemoval.PlainSubs.ShouldBeEmpty();
|
|
afterRemoval.QueueSubs.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that inserting subscriptions from one task while another task
|
|
/// is continuously calling Match does not cause crashes or produce invalid
|
|
/// results (wrong queue names, corrupted subjects).
|
|
/// Ref: testSublistRaceOnInsert (sublist_test.go:904)
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Race_on_insert_does_not_corrupt_cache()
|
|
{
|
|
var sl = new SubList();
|
|
const int total = 100;
|
|
var qsubs = new Subscription[total];
|
|
for (int i = 0; i < total; i++)
|
|
qsubs[i] = new Subscription { Subject = "foo", Queue = "bar", Sid = i.ToString() };
|
|
|
|
// Insert queue subs from background task while matching concurrently
|
|
var insertTask = Task.Run(() =>
|
|
{
|
|
foreach (var sub in qsubs)
|
|
sl.Insert(sub);
|
|
});
|
|
|
|
for (int i = 0; i < 1000; i++)
|
|
{
|
|
var r = sl.Match("foo");
|
|
foreach (var qgroup in r.QueueSubs)
|
|
{
|
|
foreach (var sub in qgroup)
|
|
sub.Queue.ShouldBe("bar");
|
|
}
|
|
}
|
|
|
|
await insertTask;
|
|
|
|
// Now repeat for plain subs
|
|
var sl2 = new SubList();
|
|
var psubs = new Subscription[total];
|
|
for (int i = 0; i < total; i++)
|
|
psubs[i] = new Subscription { Subject = "foo", Sid = i.ToString() };
|
|
|
|
var insertTask2 = Task.Run(() =>
|
|
{
|
|
foreach (var sub in psubs)
|
|
sl2.Insert(sub);
|
|
});
|
|
|
|
for (int i = 0; i < 1000; i++)
|
|
{
|
|
var r = sl2.Match("foo");
|
|
foreach (var sub in r.PlainSubs)
|
|
sub.Subject.ShouldBe("foo");
|
|
}
|
|
|
|
await insertTask2;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that multiple concurrent goroutines matching the same subject
|
|
/// simultaneously never observe corrupted subscription data (wrong subjects
|
|
/// or queue names).
|
|
/// Ref: TestSublistRaceOnMatch (sublist_test.go:956)
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Race_on_match_during_concurrent_mutations()
|
|
{
|
|
var sl = new SubList();
|
|
sl.Insert(new Subscription { Subject = "foo.*", Queue = "workers", Sid = "1" });
|
|
sl.Insert(new Subscription { Subject = "foo.bar", Queue = "workers", Sid = "2" });
|
|
sl.Insert(new Subscription { Subject = "foo.*", Sid = "3" });
|
|
sl.Insert(new Subscription { Subject = "foo.bar", Sid = "4" });
|
|
|
|
var errors = new System.Collections.Concurrent.ConcurrentBag<string>();
|
|
|
|
async Task MatchRepeatedly()
|
|
{
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
var r = sl.Match("foo.bar");
|
|
foreach (var sub in r.PlainSubs)
|
|
{
|
|
if (!sub.Subject.StartsWith("foo.", StringComparison.Ordinal))
|
|
errors.Add($"Wrong subject: {sub.Subject}");
|
|
}
|
|
foreach (var qgroup in r.QueueSubs)
|
|
{
|
|
foreach (var sub in qgroup)
|
|
{
|
|
if (sub.Queue != "workers")
|
|
errors.Add($"Wrong queue name: {sub.Queue}");
|
|
}
|
|
}
|
|
await Task.Yield();
|
|
}
|
|
}
|
|
|
|
await Task.WhenAll(MatchRepeatedly(), MatchRepeatedly());
|
|
|
|
errors.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that removing individual subscriptions from a list that has
|
|
/// crossed the high-fanout threshold (plistMin=256) produces the correct
|
|
/// remaining count. Mirrors the Go plistMin*2 scenario.
|
|
/// Ref: testSublistRemoveWithLargeSubs (sublist_test.go:330)
|
|
/// </summary>
|
|
[Fact]
|
|
public void Remove_from_large_subscription_list()
|
|
{
|
|
// plistMin in Go is 256; the .NET port uses 256 as PackedListEnabled threshold.
|
|
// We use 200 to keep the test fast while still exercising the large-list path.
|
|
const int subCount = 200;
|
|
var sl = new SubList();
|
|
var inserted = new Subscription[subCount];
|
|
|
|
for (int i = 0; i < subCount; i++)
|
|
{
|
|
inserted[i] = new Subscription { Subject = "foo", Sid = i.ToString() };
|
|
sl.Insert(inserted[i]);
|
|
}
|
|
|
|
var r = sl.Match("foo");
|
|
r.PlainSubs.Length.ShouldBe(subCount);
|
|
|
|
// Remove one from the middle, one from the start, one from the end
|
|
sl.Remove(inserted[subCount / 2]);
|
|
sl.Remove(inserted[0]);
|
|
sl.Remove(inserted[subCount - 1]);
|
|
|
|
var r2 = sl.Match("foo");
|
|
r2.PlainSubs.Length.ShouldBe(subCount - 3);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that attempting to insert subscriptions with invalid subjects
|
|
/// (empty leading or middle tokens, or a full-wildcard that is not the
|
|
/// terminal token) causes an ArgumentException to be thrown.
|
|
/// Note: a trailing dot ("foo.") is not rejected by the current .NET
|
|
/// TokenEnumerator because the empty token after the trailing separator is
|
|
/// never yielded — the Go implementation's Insert validates this via a
|
|
/// separate length check that the .NET port has not yet added.
|
|
/// Ref: testSublistInvalidSubjectsInsert (sublist_test.go:396)
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(".foo")] // leading empty token — first token is ""
|
|
[InlineData("foo..bar")] // empty middle token
|
|
[InlineData("foo.bar..baz")] // empty middle token variant
|
|
[InlineData("foo.>.bar")] // full-wildcard not terminal
|
|
public void Insert_invalid_subject_is_rejected(string subject)
|
|
{
|
|
var sl = new SubList();
|
|
var sub = new Subscription { Subject = subject, Sid = "1" };
|
|
Should.Throw<ArgumentException>(() => sl.Insert(sub));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that subjects whose tokens contain wildcard characters as part
|
|
/// of a longer token (e.g. "foo.*-", "foo.>-") are treated as literals and
|
|
/// do not match via wildcard semantics. The exact subject string matches
|
|
/// itself, but a plain "foo.bar" does not match.
|
|
/// Ref: testSublistInsertWithWildcardsAsLiterals (sublist_test.go:775)
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("foo.*-")] // token contains * but is not the single-char wildcard
|
|
[InlineData("foo.>-")] // token contains > but is not the single-char wildcard
|
|
public void Wildcards_as_literals_not_matched_as_wildcards(string subject)
|
|
{
|
|
var sl = new SubList();
|
|
var sub = new Subscription { Subject = subject, Sid = "1" };
|
|
sl.Insert(sub);
|
|
|
|
// A subject that would match if * / > were real wildcards must NOT match
|
|
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
|
|
|
// The literal subject itself must match exactly
|
|
sl.Match(subject).PlainSubs.ShouldHaveSingleItem();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies edge-case handling for subjects with empty tokens at different
|
|
/// positions. Empty string, leading dot, and consecutive dots produce no
|
|
/// match results (the Tokenize helper returns null for invalid subjects).
|
|
/// Insert with leading or middle empty tokens throws ArgumentException.
|
|
/// Note: "foo." (trailing dot) is not rejected by Insert because the
|
|
/// TokenEnumerator stops before yielding the trailing empty token — it is
|
|
/// a known behavioural gap vs. Go that does not affect correctness of the
|
|
/// trie but is documented here for future parity work.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Empty_subject_tokens_handled()
|
|
{
|
|
var sl = new SubList();
|
|
|
|
// Insert a valid sub so the list is not empty
|
|
sl.Insert(MakeSub("foo.bar", sid: "valid"));
|
|
|
|
// Matching against subjects with empty tokens returns no results
|
|
// (the Match tokenizer returns null / empty for invalid subjects)
|
|
sl.Match("").PlainSubs.ShouldBeEmpty();
|
|
sl.Match("foo..bar").PlainSubs.ShouldBeEmpty();
|
|
sl.Match(".foo").PlainSubs.ShouldBeEmpty();
|
|
sl.Match("foo.").PlainSubs.ShouldBeEmpty();
|
|
|
|
// Inserting a subject with a leading empty token throws
|
|
Should.Throw<ArgumentException>(() => sl.Insert(new Subscription { Subject = ".foo", Sid = "x" }));
|
|
// Inserting a subject with a middle empty token throws
|
|
Should.Throw<ArgumentException>(() => sl.Insert(new Subscription { Subject = "foo..bar", Sid = "x" }));
|
|
|
|
// The original valid sub remains unaffected — failed inserts must not corrupt state
|
|
sl.Count.ShouldBe(1u);
|
|
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
|
}
|
|
}
|