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 // ----------------------------------------------------------------------- /// /// 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) /// [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(); } /// /// 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) /// [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; } /// /// 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) /// [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(); 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(); } /// /// 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) /// [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); } /// /// 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) /// [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(() => sl.Insert(sub)); } /// /// 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) /// [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(); } /// /// 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. /// [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(() => sl.Insert(new Subscription { Subject = ".foo", Sid = "x" })); // Inserting a subject with a middle empty token throws Should.Throw(() => 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(); } }