feat: port internal data structures from Go (Wave 2)

- AVL SequenceSet: sparse sequence set with AVL tree, 16 tests
- Subject Tree: Adaptive Radix Tree (ART) with 5 node tiers, 59 tests
- Generic Subject List: trie-based subject matcher, 21 tests
- Time Hash Wheel: O(1) TTL expiration wheel, 8 tests

Total: 106 new tests (1,081 → 1,187 passing)
This commit is contained in:
Joseph Doherty
2026-02-23 20:56:20 -05:00
parent 636906f545
commit 256daad8e5
10 changed files with 6402 additions and 8 deletions

View File

@@ -0,0 +1,540 @@
// Copyright 2024 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System.Diagnostics;
using NATS.Server.Internal.Avl;
namespace NATS.Server.Tests.Internal.Avl;
/// <summary>
/// Tests for the AVL-backed SequenceSet, ported from Go server/avl/seqset_test.go
/// and server/avl/norace_test.go.
/// </summary>
public class SequenceSetTests
{
private const int NumEntries = SequenceSet.NumEntries; // 2048
private const int BitsPerBucket = SequenceSet.BitsPerBucket;
private const int NumBuckets = SequenceSet.NumBuckets;
// Go: TestSeqSetBasics server/avl/seqset_test.go:22
[Fact]
public void Basics_InsertExistsDelete()
{
var ss = new SequenceSet();
ulong[] seqs = [22, 222, 2000, 2, 2, 4];
foreach (var seq in seqs)
{
ss.Insert(seq);
ss.Exists(seq).ShouldBeTrue();
}
ss.Nodes.ShouldBe(1);
ss.Size.ShouldBe(seqs.Length - 1); // One dup (2 appears twice)
var (lh, rh) = ss.Heights();
lh.ShouldBe(0);
rh.ShouldBe(0);
}
// Go: TestSeqSetLeftLean server/avl/seqset_test.go:38
[Fact]
public void LeftLean_TreeBalancesCorrectly()
{
var ss = new SequenceSet();
// Insert from high to low to create a left-leaning tree.
for (var i = (ulong)(4 * NumEntries); i > 0; i--)
{
ss.Insert(i);
}
ss.Nodes.ShouldBe(5);
ss.Size.ShouldBe(4 * NumEntries);
var (lh, rh) = ss.Heights();
lh.ShouldBe(2);
rh.ShouldBe(1);
}
// Go: TestSeqSetRightLean server/avl/seqset_test.go:52
[Fact]
public void RightLean_TreeBalancesCorrectly()
{
var ss = new SequenceSet();
// Insert from low to high to create a right-leaning tree.
for (var i = 0UL; i < (ulong)(4 * NumEntries); i++)
{
ss.Insert(i);
}
ss.Nodes.ShouldBe(4);
ss.Size.ShouldBe(4 * NumEntries);
var (lh, rh) = ss.Heights();
lh.ShouldBe(1);
rh.ShouldBe(2);
}
// Go: TestSeqSetCorrectness server/avl/seqset_test.go:66
[Fact]
public void Correctness_RandomInsertDelete()
{
// Generate 100k sequences across 500k range.
const int num = 100_000;
const int max = 500_000;
var rng = new Random(42);
var set = new HashSet<ulong>();
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
{
var n = (ulong)rng.NextInt64(max + 1);
ss.Insert(n);
set.Add(n);
}
for (var i = 0UL; i <= max; i++)
{
ss.Exists(i).ShouldBe(set.Contains(i));
}
}
// Go: TestSeqSetRange server/avl/seqset_test.go:85
[Fact]
public void Range_IteratesInOrder()
{
var num = 2 * NumEntries + 22;
var nums = new List<ulong>(num);
for (var i = 0; i < num; i++)
{
nums.Add((ulong)i);
}
// Shuffle and insert.
var rng = new Random(42);
Shuffle(nums, rng);
var ss = new SequenceSet();
foreach (var n in nums)
{
ss.Insert(n);
}
// Range should produce ascending order.
var result = new List<ulong>();
ss.Range(n =>
{
result.Add(n);
return true;
});
result.Count.ShouldBe(num);
for (var i = 0UL; i < (ulong)num; i++)
{
result[(int)i].ShouldBe(i);
}
// Test truncating the range call.
result.Clear();
ss.Range(n =>
{
if (n >= 10)
{
return false;
}
result.Add(n);
return true;
});
result.Count.ShouldBe(10);
for (var i = 0UL; i < 10; i++)
{
result[(int)i].ShouldBe(i);
}
}
// Go: TestSeqSetDelete server/avl/seqset_test.go:123
[Fact]
public void Delete_VariousPatterns()
{
var ss = new SequenceSet();
ulong[] seqs = [22, 222, 2222, 2, 2, 4];
foreach (var seq in seqs)
{
ss.Insert(seq);
}
foreach (var seq in seqs)
{
ss.Delete(seq);
ss.Exists(seq).ShouldBeFalse();
}
ss.Root.ShouldBeNull();
}
// Go: TestSeqSetInsertAndDeletePedantic server/avl/seqset_test.go:139
[Fact]
public void InsertAndDelete_PedanticVerification()
{
var ss = new SequenceSet();
var num = 50 * NumEntries + 22;
var nums = new List<ulong>(num);
for (var i = 0; i < num; i++)
{
nums.Add((ulong)i);
}
var rng = new Random(42);
Shuffle(nums, rng);
// Insert all, verify balanced after each insert.
foreach (var n in nums)
{
ss.Insert(n);
VerifyBalanced(ss);
}
ss.Root.ShouldNotBeNull();
// Delete all, verify balanced after each delete.
foreach (var n in nums)
{
ss.Delete(n);
VerifyBalanced(ss);
ss.Exists(n).ShouldBeFalse();
if (ss.Size > 0)
{
ss.Root.ShouldNotBeNull();
}
}
ss.Root.ShouldBeNull();
}
// Go: TestSeqSetMinMax server/avl/seqset_test.go:181
[Fact]
public void MinMax_TracksCorrectly()
{
var ss = new SequenceSet();
// Simple single node.
ulong[] seqs = [22, 222, 2222, 2, 2, 4];
foreach (var seq in seqs)
{
ss.Insert(seq);
}
var (min, max) = ss.MinMax();
min.ShouldBe(2UL);
max.ShouldBe(2222UL);
// Multi-node
ss.Empty();
var num = 22 * NumEntries + 22;
var nums = new List<ulong>(num);
for (var i = 0; i < num; i++)
{
nums.Add((ulong)i);
}
var rng = new Random(42);
Shuffle(nums, rng);
foreach (var n in nums)
{
ss.Insert(n);
}
(min, max) = ss.MinMax();
min.ShouldBe(0UL);
max.ShouldBe((ulong)(num - 1));
}
// Go: TestSeqSetClone server/avl/seqset_test.go:210
[Fact]
public void Clone_IndependentCopy()
{
// Generate 100k sequences across 500k range.
const int num = 100_000;
const int max = 500_000;
var rng = new Random(42);
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
{
ss.Insert((ulong)rng.NextInt64(max + 1));
}
var ssc = ss.Clone();
ssc.Size.ShouldBe(ss.Size);
ssc.Nodes.ShouldBe(ss.Nodes);
}
// Go: TestSeqSetUnion server/avl/seqset_test.go:225
[Fact]
public void Union_MergesSets()
{
var ss1 = new SequenceSet();
var ss2 = new SequenceSet();
ulong[] seqs1 = [22, 222, 2222, 2, 2, 4];
foreach (var seq in seqs1)
{
ss1.Insert(seq);
}
ulong[] seqs2 = [33, 333, 3333, 3, 33_333, 333_333];
foreach (var seq in seqs2)
{
ss2.Insert(seq);
}
var ss = SequenceSet.CreateUnion(ss1, ss2);
ss.Size.ShouldBe(11);
ulong[] allSeqs = [.. seqs1, .. seqs2];
foreach (var n in allSeqs)
{
ss.Exists(n).ShouldBeTrue();
}
}
// Go: TestSeqSetFirst server/avl/seqset_test.go:247
[Fact]
public void First_ReturnsMinimum()
{
var ss = new SequenceSet();
ulong[] seqs = [22, 222, 2222, 222_222];
foreach (var seq in seqs)
{
// Normal case where we pick first/base.
ss.Insert(seq);
ss.Root!.Base.ShouldBe((seq / (ulong)NumEntries) * (ulong)NumEntries);
ss.Empty();
// Where we set the minimum start value.
ss.SetInitialMin(seq);
ss.Insert(seq);
ss.Root!.Base.ShouldBe(seq);
ss.Empty();
}
}
// Go: TestSeqSetDistinctUnion server/avl/seqset_test.go:265
[Fact]
public void DistinctUnion_NoOverlap()
{
var ss1 = new SequenceSet();
ulong[] seqs1 = [1, 10, 100, 200];
foreach (var seq in seqs1)
{
ss1.Insert(seq);
}
var ss2 = new SequenceSet();
ulong[] seqs2 = [5000, 6100, 6200, 6222];
foreach (var seq in seqs2)
{
ss2.Insert(seq);
}
var ss = ss1.Clone();
ulong[] allSeqs = [.. seqs1, .. seqs2];
ss.Union(ss2);
ss.Size.ShouldBe(allSeqs.Length);
foreach (var seq in allSeqs)
{
ss.Exists(seq).ShouldBeTrue();
}
}
// Go: TestSeqSetDecodeV1 server/avl/seqset_test.go:289
[Fact]
public void DecodeV1_BackwardsCompatible()
{
// Encoding from v1 which was 64 buckets.
ulong[] seqs = [22, 222, 2222, 222_222, 2_222_222];
var encStr =
"FgEDAAAABQAAAABgAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADgIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAA==";
var enc = Convert.FromBase64String(encStr);
var (ss, _) = SequenceSet.Decode(enc);
ss.Size.ShouldBe(seqs.Length);
foreach (var seq in seqs)
{
ss.Exists(seq).ShouldBeTrue();
}
}
// Go: TestNoRaceSeqSetSizeComparison server/avl/norace_test.go:33
[Fact]
public void SizeComparison_LargeSet()
{
// Create 5M random entries out of 7M range.
const int num = 5_000_000;
const int max = 7_000_000;
var rng = new Random(42);
var seqs = new ulong[num];
for (var i = 0; i < num; i++)
{
seqs[i] = (ulong)rng.NextInt64(max + 1);
}
// Insert into a dictionary to compare.
var dmap = new HashSet<ulong>(num);
foreach (var n in seqs)
{
dmap.Add(n);
}
// Insert into SequenceSet.
var ss = new SequenceSet();
foreach (var n in seqs)
{
ss.Insert(n);
}
// Verify sizes match.
ss.Size.ShouldBe(dmap.Count);
// Verify SequenceSet uses very few nodes relative to its element count.
// With 2048 entries per node and 7M range, we expect ~ceil(7M/2048) = ~3419 nodes at most.
ss.Nodes.ShouldBeLessThan(5000);
}
// Go: TestNoRaceSeqSetEncodeLarge server/avl/norace_test.go:81
[Fact]
public void EncodeLarge_RoundTrips()
{
const int num = 2_500_000;
const int max = 5_000_000;
var rng = new Random(42);
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
{
ss.Insert((ulong)rng.NextInt64(max + 1));
}
var sw = Stopwatch.StartNew();
var buf = ss.Encode();
sw.Stop();
// Encode should be fast (the Go test uses 1ms, we allow more for .NET JIT).
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
sw.Restart();
var (ss2, bytesRead) = SequenceSet.Decode(buf);
sw.Stop();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
bytesRead.ShouldBe(buf.Length);
ss2.Nodes.ShouldBe(ss.Nodes);
ss2.Size.ShouldBe(ss.Size);
}
// Go: TestNoRaceSeqSetRelativeSpeed server/avl/norace_test.go:123
[Fact]
public void RelativeSpeed_Performance()
{
const int num = 1_000_000;
const int max = 3_000_000;
var rng = new Random(42);
var seqs = new ulong[num];
for (var i = 0; i < num; i++)
{
seqs[i] = (ulong)rng.NextInt64(max + 1);
}
// SequenceSet insert.
var sw = Stopwatch.StartNew();
var ss = new SequenceSet();
foreach (var n in seqs)
{
ss.Insert(n);
}
var ssInsert = sw.Elapsed;
// SequenceSet lookup.
sw.Restart();
foreach (var n in seqs)
{
ss.Exists(n).ShouldBeTrue();
}
var ssLookup = sw.Elapsed;
// Dictionary insert.
sw.Restart();
var dmap = new HashSet<ulong>();
foreach (var n in seqs)
{
dmap.Add(n);
}
var mapInsert = sw.Elapsed;
// Dictionary lookup.
sw.Restart();
foreach (var n in seqs)
{
dmap.Contains(n).ShouldBeTrue();
}
var mapLookup = sw.Elapsed;
// Relaxed bounds: SequenceSet insert should be no more than 10x slower.
// (.NET JIT and test host overhead can be significant vs Go's simpler runtime.)
ssInsert.ShouldBeLessThan(mapInsert * 10);
ssLookup.ShouldBeLessThan(mapLookup * 10);
}
/// <summary>Verifies the AVL tree is balanced at every node.</summary>
private static void VerifyBalanced(SequenceSet ss)
{
if (ss.Root == null)
{
return;
}
// Check all node heights and balance factors.
SequenceSet.Node.NodeIter(ss.Root, n =>
{
var expectedHeight = SequenceSet.Node.MaxHeight(n) + 1;
n.Height.ShouldBe(expectedHeight, $"Node height is wrong for node with base {n.Base}");
});
var bf = SequenceSet.Node.BalanceFactor(ss.Root);
bf.ShouldBeInRange(-1, 1, "Tree is unbalanced at root");
}
/// <summary>Fisher-Yates shuffle.</summary>
private static void Shuffle(List<ulong> list, Random rng)
{
for (var i = list.Count - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
}

View File

@@ -0,0 +1,429 @@
// Go reference: server/gsl/gsl_test.go
// Tests for GenericSubjectList<T> trie-based subject matching.
using NATS.Server.Internal.Gsl;
namespace NATS.Server.Tests.Internal.Gsl;
public class GenericSubjectListTests
{
/// <summary>
/// Helper: count matches for a subject.
/// </summary>
private static int CountMatches<T>(GenericSubjectList<T> s, string subject) where T : IEquatable<T>
{
var count = 0;
s.Match(subject, _ => count++);
return count;
}
// Go: TestGenericSublistInit server/gsl/gsl_test.go:23
[Fact]
public void Init_EmptyList()
{
var s = new GenericSubjectList<int>();
s.Count.ShouldBe(0u);
}
// Go: TestGenericSublistInsertCount server/gsl/gsl_test.go:29
[Fact]
public void InsertCount_TracksCorrectly()
{
var s = new GenericSubjectList<int>();
s.Insert("foo", 1);
s.Insert("bar", 2);
s.Insert("foo.bar", 3);
s.Count.ShouldBe(3u);
}
// Go: TestGenericSublistSimple server/gsl/gsl_test.go:37
[Fact]
public void Simple_ExactMatch()
{
var s = new GenericSubjectList<int>();
s.Insert("foo", 1);
CountMatches(s, "foo").ShouldBe(1);
}
// Go: TestGenericSublistSimpleMultiTokens server/gsl/gsl_test.go:43
[Fact]
public void SimpleMultiTokens_Match()
{
var s = new GenericSubjectList<int>();
s.Insert("foo.bar.baz", 1);
CountMatches(s, "foo.bar.baz").ShouldBe(1);
}
// Go: TestGenericSublistPartialWildcard server/gsl/gsl_test.go:49
[Fact]
public void PartialWildcard_StarMatches()
{
var s = new GenericSubjectList<int>();
s.Insert("a.b.c", 1);
s.Insert("a.*.c", 2);
CountMatches(s, "a.b.c").ShouldBe(2);
}
// Go: TestGenericSublistPartialWildcardAtEnd server/gsl/gsl_test.go:56
[Fact]
public void PartialWildcardAtEnd_StarMatches()
{
var s = new GenericSubjectList<int>();
s.Insert("a.b.c", 1);
s.Insert("a.b.*", 2);
CountMatches(s, "a.b.c").ShouldBe(2);
}
// Go: TestGenericSublistFullWildcard server/gsl/gsl_test.go:63
[Fact]
public void FullWildcard_GreaterThanMatches()
{
var s = new GenericSubjectList<int>();
s.Insert("a.b.c", 1);
s.Insert("a.>", 2);
CountMatches(s, "a.b.c").ShouldBe(2);
CountMatches(s, "a.>").ShouldBe(1);
}
// Go: TestGenericSublistRemove server/gsl/gsl_test.go:71
[Fact]
public void Remove_DecreasesCount()
{
var s = new GenericSubjectList<int>();
s.Insert("a.b.c.d", 1);
s.Count.ShouldBe(1u);
CountMatches(s, "a.b.c.d").ShouldBe(1);
s.Remove("a.b.c.d", 1);
s.Count.ShouldBe(0u);
CountMatches(s, "a.b.c.d").ShouldBe(0);
}
// Go: TestGenericSublistRemoveWildcard server/gsl/gsl_test.go:83
[Fact]
public void RemoveWildcard_CleansUp()
{
var s = new GenericSubjectList<int>();
s.Insert("a.b.c.d", 11);
s.Insert("a.b.*.d", 22);
s.Insert("a.b.>", 33);
s.Count.ShouldBe(3u);
CountMatches(s, "a.b.c.d").ShouldBe(3);
s.Remove("a.b.*.d", 22);
s.Count.ShouldBe(2u);
CountMatches(s, "a.b.c.d").ShouldBe(2);
s.Remove("a.b.>", 33);
s.Count.ShouldBe(1u);
CountMatches(s, "a.b.c.d").ShouldBe(1);
s.Remove("a.b.c.d", 11);
s.Count.ShouldBe(0u);
CountMatches(s, "a.b.c.d").ShouldBe(0);
}
// Go: TestGenericSublistRemoveCleanup server/gsl/gsl_test.go:105
[Fact]
public void RemoveCleanup_PrunesEmptyNodes()
{
var s = new GenericSubjectList<int>();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.c.d.e.f", 1);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.c.d.e.f", 1);
s.NumLevels().ShouldBe(0);
}
// Go: TestGenericSublistRemoveCleanupWildcards server/gsl/gsl_test.go:114
[Fact]
public void RemoveCleanupWildcards_PrunesEmptyNodes()
{
var s = new GenericSubjectList<int>();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.*.d.e.>", 1);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.*.d.e.>", 1);
s.NumLevels().ShouldBe(0);
}
// Go: TestGenericSublistInvalidSubjectsInsert server/gsl/gsl_test.go:123
[Fact]
public void InvalidSubjectsInsert_RejectsInvalid()
{
var s = new GenericSubjectList<int>();
// Empty tokens and FWC not terminal
Should.Throw<InvalidOperationException>(() => s.Insert(".foo", 1));
Should.Throw<InvalidOperationException>(() => s.Insert("foo.", 1));
Should.Throw<InvalidOperationException>(() => s.Insert("foo..bar", 1));
Should.Throw<InvalidOperationException>(() => s.Insert("foo.bar..baz", 1));
Should.Throw<InvalidOperationException>(() => s.Insert("foo.>.baz", 1));
}
// Go: TestGenericSublistBadSubjectOnRemove server/gsl/gsl_test.go:134
[Fact]
public void BadSubjectOnRemove_RejectsInvalid()
{
var s = new GenericSubjectList<int>();
Should.Throw<InvalidOperationException>(() => s.Insert("a.b..d", 1));
Should.Throw<InvalidOperationException>(() => s.Remove("a.b..d", 1));
Should.Throw<InvalidOperationException>(() => s.Remove("a.>.b", 1));
}
// Go: TestGenericSublistTwoTokenPubMatchSingleTokenSub server/gsl/gsl_test.go:141
[Fact]
public void TwoTokenPub_DoesNotMatchSingleTokenSub()
{
var s = new GenericSubjectList<int>();
s.Insert("foo", 1);
CountMatches(s, "foo").ShouldBe(1);
CountMatches(s, "foo.bar").ShouldBe(0);
}
// Go: TestGenericSublistInsertWithWildcardsAsLiterals server/gsl/gsl_test.go:148
[Fact]
public void InsertWithWildcardsAsLiterals_TreatsAsLiteral()
{
var s = new GenericSubjectList<int>();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
s.Insert(subjects[i], i);
CountMatches(s, "foo.bar").ShouldBe(0);
CountMatches(s, subjects[i]).ShouldBe(1);
}
}
// Go: TestGenericSublistRemoveWithWildcardsAsLiterals server/gsl/gsl_test.go:157
[Fact]
public void RemoveWithWildcardsAsLiterals_RemovesCorrectly()
{
var s = new GenericSubjectList<int>();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
s.Insert(subjects[i], i);
CountMatches(s, "foo.bar").ShouldBe(0);
CountMatches(s, subjects[i]).ShouldBe(1);
Should.Throw<KeyNotFoundException>(() => s.Remove("foo.bar", i));
s.Count.ShouldBe(1u);
s.Remove(subjects[i], i);
s.Count.ShouldBe(0u);
}
}
// Go: TestGenericSublistMatchWithEmptyTokens server/gsl/gsl_test.go:170
[Theory]
[InlineData(".foo")]
[InlineData("..foo")]
[InlineData("foo..")]
[InlineData("foo.")]
[InlineData("foo..bar")]
[InlineData("foo...bar")]
public void MatchWithEmptyTokens_HandlesEdgeCase(string subject)
{
var s = new GenericSubjectList<int>();
s.Insert(">", 1);
CountMatches(s, subject).ShouldBe(0);
}
// Go: TestGenericSublistHasInterest server/gsl/gsl_test.go:180
[Fact]
public void HasInterest_ReturnsTrueForMatchingSubjects()
{
var s = new GenericSubjectList<int>();
s.Insert("foo", 11);
// Expect to find that "foo" matches but "bar" doesn't.
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("bar").ShouldBeFalse();
// Call Match on a subject we know there is no match.
CountMatches(s, "bar").ShouldBe(0);
s.HasInterest("bar").ShouldBeFalse();
// Remove fooSub and check interest again
s.Remove("foo", 11);
s.HasInterest("foo").ShouldBeFalse();
// Try with partial wildcard *
s.Insert("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
// Remove sub, there should be no interest
s.Remove("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
// Try with full wildcard >
s.Insert("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeTrue();
s.Remove("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
// Try with *.>
s.Insert("*.>", 44);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeTrue();
s.Remove("*.>", 44);
// Try with *.bar
s.Insert("*.bar", 55);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeFalse();
s.Remove("*.bar", 55);
// Try with *
s.Insert("*", 66);
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("foo.bar").ShouldBeFalse();
s.Remove("*", 66);
}
// Go: TestGenericSublistHasInterestOverlapping server/gsl/gsl_test.go:237
[Fact]
public void HasInterestOverlapping_HandlesOverlap()
{
var s = new GenericSubjectList<int>();
s.Insert("stream.A.child", 11);
s.Insert("stream.*", 11);
s.HasInterest("stream.A.child").ShouldBeTrue();
s.HasInterest("stream.A").ShouldBeTrue();
}
// Go: TestGenericSublistHasInterestStartingInRace server/gsl/gsl_test.go:247
[Fact]
public async Task HasInterestStartingIn_ThreadSafe()
{
var s = new GenericSubjectList<int>();
// Pre-populate with some patterns
for (var i = 0; i < 10; i++)
{
s.Insert("foo.bar.baz", i);
s.Insert("foo.*.baz", i + 10);
s.Insert("foo.>", i + 20);
}
const int iterations = 1000;
var tasks = new List<Task>();
// Task 1: repeatedly call HasInterestStartingIn
tasks.Add(Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterestStartingIn("foo");
s.HasInterestStartingIn("foo.bar");
s.HasInterestStartingIn("foo.bar.baz");
s.HasInterestStartingIn("other.subject");
}
}));
// Task 2: repeatedly modify the sublist
tasks.Add(Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
var val = 1000 + i;
var ch = (char)('a' + (i % 26));
s.Insert($"test.subject.{ch}", val);
s.Insert("foo.*.test", val);
s.Remove($"test.subject.{ch}", val);
s.Remove("foo.*.test", val);
}
}));
// Task 3: also call HasInterest (which does lock)
tasks.Add(Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterest("foo.bar.baz");
s.HasInterest("foo.something.baz");
}
}));
// Wait for all tasks - should not throw (no deadlocks or data races)
await Task.WhenAll(tasks);
}
// Go: TestGenericSublistNumInterest server/gsl/gsl_test.go:298
[Fact]
public void NumInterest_CountsMatchingSubscriptions()
{
var s = new GenericSubjectList<int>();
s.Insert("foo", 11);
// Helper to check both Match count and NumInterest agree
void RequireNumInterest(string subj, int expected)
{
CountMatches(s, subj).ShouldBe(expected);
s.NumInterest(subj).ShouldBe(expected);
}
// Expect to find that "foo" matches but "bar" doesn't.
RequireNumInterest("foo", 1);
RequireNumInterest("bar", 0);
// Remove fooSub and check interest again
s.Remove("foo", 11);
RequireNumInterest("foo", 0);
// Try with partial wildcard *
s.Insert("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
// Remove sub, there should be no interest
s.Remove("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
// Full wildcard >
s.Insert("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
// *.>
s.Insert("*.>", 44);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("*.>", 44);
// *.bar
s.Insert("*.bar", 55);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
s.Remove("*.bar", 55);
// *
s.Insert("*", 66);
RequireNumInterest("foo", 1);
RequireNumInterest("foo.bar", 0);
s.Remove("*", 66);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
// Go reference: server/thw/thw_test.go
using NATS.Server.Internal.TimeHashWheel;
namespace NATS.Server.Tests.Internal.TimeHashWheel;
public class HashWheelTests
{
/// <summary>
/// Helper to produce nanosecond timestamps relative to a base, matching
/// the Go test pattern of now.Add(N * time.Second).UnixNano().
/// </summary>
private static long NowNanos() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000;
private static long SecondsToNanos(long seconds) => seconds * 1_000_000_000;
// Go: TestHashWheelBasics server/thw/thw_test.go:22
[Fact]
public void Basics_AddRemoveCount()
{
var hw = new HashWheel();
var now = NowNanos();
// Add a sequence.
ulong seq = 1;
var expires = now + SecondsToNanos(5);
hw.Add(seq, expires);
hw.Count.ShouldBe(1UL);
// Try to remove non-existent sequence.
hw.Remove(999, expires).ShouldBeFalse();
hw.Count.ShouldBe(1UL);
// Remove the sequence properly.
hw.Remove(seq, expires).ShouldBeTrue();
hw.Count.ShouldBe(0UL);
// Verify it's gone.
hw.Remove(seq, expires).ShouldBeFalse();
hw.Count.ShouldBe(0UL);
}
// Go: TestHashWheelUpdate server/thw/thw_test.go:44
[Fact]
public void Update_ChangesExpiration()
{
var hw = new HashWheel();
var now = NowNanos();
var oldExpires = now + SecondsToNanos(5);
var newExpires = now + SecondsToNanos(10);
// Add initial sequence.
hw.Add(1, oldExpires);
hw.Count.ShouldBe(1UL);
// Update expiration.
hw.Update(1, oldExpires, newExpires);
hw.Count.ShouldBe(1UL);
// Verify old expiration is gone.
hw.Remove(1, oldExpires).ShouldBeFalse();
hw.Count.ShouldBe(1UL);
// Verify new expiration exists.
hw.Remove(1, newExpires).ShouldBeTrue();
hw.Count.ShouldBe(0UL);
}
// Go: TestHashWheelExpiration server/thw/thw_test.go:67
[Fact]
public void Expiration_FiresCallbackForExpired()
{
var hw = new HashWheel();
var now = NowNanos();
// Add sequences with different expiration times.
var seqs = new Dictionary<ulong, long>
{
[1] = now - SecondsToNanos(1), // Already expired
[2] = now + SecondsToNanos(1), // Expires soon
[3] = now + SecondsToNanos(10), // Expires later
[4] = now + SecondsToNanos(60), // Expires much later
};
foreach (var (seq, expires) in seqs)
{
hw.Add(seq, expires);
}
hw.Count.ShouldBe((ulong)seqs.Count);
// Process expired tasks using internal method with explicit "now" timestamp.
var expired = new Dictionary<ulong, bool>();
hw.ExpireTasksInternal(now, (seq, _) =>
{
expired[seq] = true;
return true;
});
// Verify only sequence 1 expired.
expired.Count.ShouldBe(1);
expired.ShouldContainKey(1UL);
hw.Count.ShouldBe(3UL);
}
// Go: TestHashWheelManualExpiration server/thw/thw_test.go:97
[Fact]
public void ManualExpiration_SpecificTime()
{
var hw = new HashWheel();
var now = NowNanos();
for (ulong seq = 1; seq <= 4; seq++)
{
hw.Add(seq, now);
}
hw.Count.ShouldBe(4UL);
// Loop over expired multiple times, but without removing them.
var expired = new Dictionary<ulong, ulong>();
for (ulong i = 0; i <= 1; i++)
{
hw.ExpireTasksInternal(now, (seq, _) =>
{
if (!expired.TryGetValue(seq, out var count))
{
count = 0;
}
expired[seq] = count + 1;
return false;
});
expired.Count.ShouldBe(4);
expired[1].ShouldBe(1 + i);
expired[2].ShouldBe(1 + i);
expired[3].ShouldBe(1 + i);
expired[4].ShouldBe(1 + i);
hw.Count.ShouldBe(4UL);
}
// Only remove even sequences.
for (ulong i = 0; i <= 1; i++)
{
hw.ExpireTasksInternal(now, (seq, _) =>
{
if (!expired.TryGetValue(seq, out var count))
{
count = 0;
}
expired[seq] = count + 1;
return seq % 2 == 0;
});
// Verify even sequences are removed.
expired[1].ShouldBe(3 + i);
expired[2].ShouldBe(3UL);
expired[3].ShouldBe(3 + i);
expired[4].ShouldBe(3UL);
hw.Count.ShouldBe(2UL);
}
// Manually remove last items.
hw.Remove(1, now).ShouldBeTrue();
hw.Remove(3, now).ShouldBeTrue();
hw.Count.ShouldBe(0UL);
}
// Go: TestHashWheelExpirationLargerThanWheel server/thw/thw_test.go:143
[Fact]
public void LargerThanWheel_HandlesWrapAround()
{
var hw = new HashWheel();
// Add sequences such that they can be expired immediately.
var seqs = new Dictionary<ulong, long>
{
[1] = 0,
[2] = SecondsToNanos(1),
};
foreach (var (seq, expires) in seqs)
{
hw.Add(seq, expires);
}
hw.Count.ShouldBe(2UL);
// Pick a timestamp such that the expiration needs to wrap around the whole wheel.
// Go: now := int64(time.Second) * wheelMask
var now = SecondsToNanos(1) * HashWheel.WheelSize - SecondsToNanos(1);
// Process expired tasks.
var expired = new Dictionary<ulong, bool>();
hw.ExpireTasksInternal(now, (seq, _) =>
{
expired[seq] = true;
return true;
});
// Verify both sequences are expired.
expired.Count.ShouldBe(2);
hw.Count.ShouldBe(0UL);
}
// Go: TestHashWheelNextExpiration server/thw/thw_test.go:171
[Fact]
public void NextExpiration_FindsEarliest()
{
var hw = new HashWheel();
var now = NowNanos();
// Add sequences with different expiration times.
var seqs = new Dictionary<ulong, long>
{
[1] = now + SecondsToNanos(5),
[2] = now + SecondsToNanos(3), // Earliest
[3] = now + SecondsToNanos(10),
};
foreach (var (seq, expires) in seqs)
{
hw.Add(seq, expires);
}
hw.Count.ShouldBe((ulong)seqs.Count);
// Test GetNextExpiration.
var nextExternalTick = now + SecondsToNanos(6);
// Should return sequence 2's expiration.
hw.GetNextExpiration(nextExternalTick).ShouldBe(seqs[2]);
// Test with empty wheel.
var empty = new HashWheel();
empty.GetNextExpiration(now + SecondsToNanos(1)).ShouldBe(long.MaxValue);
}
// Go: TestHashWheelStress server/thw/thw_test.go:197
[Fact]
public void Stress_ConcurrentAddRemove()
{
var hw = new HashWheel();
var now = NowNanos();
const int numSequences = 100_000;
// Add many sequences.
for (var seq = 0; seq < numSequences; seq++)
{
var expires = now + SecondsToNanos(seq);
hw.Add((ulong)seq, expires);
}
// Update many sequences (every other one).
for (var seq = 0; seq < numSequences; seq += 2)
{
var oldExpires = now + SecondsToNanos(seq);
var newExpires = now + SecondsToNanos(seq + numSequences);
hw.Update((ulong)seq, oldExpires, newExpires);
}
// Remove odd-numbered sequences.
for (var seq = 1; seq < numSequences; seq += 2)
{
var expires = now + SecondsToNanos(seq);
hw.Remove((ulong)seq, expires).ShouldBeTrue();
}
// After updates and removals, only half remain (the even ones with updated expiration).
hw.Count.ShouldBe((ulong)(numSequences / 2));
}
// Go: TestHashWheelEncodeDecode server/thw/thw_test.go:222
[Fact]
public void EncodeDecode_RoundTrips()
{
var hw = new HashWheel();
var now = NowNanos();
const int numSequences = 100_000;
// Add many sequences.
for (var seq = 0; seq < numSequences; seq++)
{
var expires = now + SecondsToNanos(seq);
hw.Add((ulong)seq, expires);
}
var encoded = hw.Encode(12345);
encoded.Length.ShouldBeGreaterThan(17); // Bigger than just the header.
var nhw = new HashWheel();
var (highSeq, bytesRead) = nhw.Decode(encoded);
highSeq.ShouldBe(12345UL);
bytesRead.ShouldBe(encoded.Length);
hw.GetNextExpiration(long.MaxValue).ShouldBe(nhw.GetNextExpiration(long.MaxValue));
// Verify all slots match.
for (var s = 0; s < HashWheel.WheelSize; s++)
{
var slot = hw.Wheel[s];
var nslot = nhw.Wheel[s];
if (slot is null)
{
nslot.ShouldBeNull();
continue;
}
nslot.ShouldNotBeNull();
slot.Lowest.ShouldBe(nslot!.Lowest);
slot.Entries.Count.ShouldBe(nslot.Entries.Count);
foreach (var (seq, ts) in slot.Entries)
{
nslot.Entries.ShouldContainKey(seq);
nslot.Entries[seq].ShouldBe(ts);
}
}
}
}