Files
natsdotnet/tests/NATS.Server.Tests/SubListTests.cs
Joseph Doherty 7ffee8741f feat: phase A foundation test parity — 64 new tests across 11 subsystems
Port Go NATS server test behaviors to .NET:
- Client pub/sub (5 tests): simple, no-echo, reply, queue distribution, empty body
- Client UNSUB (4 tests): unsub, auto-unsub max, unsub after auto, disconnect cleanup
- Client headers (3 tests): HPUB/HMSG, server info headers, no-responders 503
- Client lifecycle (3 tests): connect proto, max subscriptions, auth timeout
- Client slow consumer (1 test): pending limit detection and disconnect
- Parser edge cases (3 tests + 2 bug fixes): PUB arg variations, malformed protocol, max control line
- SubList concurrency (13 tests): race on remove/insert/match, large lists, invalid subjects, wildcards
- Server config (4 tests): ephemeral port, server name, name defaults, lame duck
- Route config (3 tests): cluster formation, cross-cluster messaging, reconnect
- Gateway basic (2 tests): cross-cluster forwarding, no echo to origin
- Leaf node basic (2 tests): hub-to-spoke and spoke-to-hub forwarding
- Account import/export (2 tests): stream export/import delivery, isolation

Also fixes NatsParser.ParseSub/ParseUnsub to throw ProtocolViolationException
for short command lines instead of ArgumentOutOfRangeException.

Full suite: 933 passed, 0 failed (up from 869).
2026-02-23 19:26:30 -05:00

550 lines
18 KiB
C#

using NATS.Server.Subscriptions;
namespace NATS.Server.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();
}
}