// Go reference: server/thw/thw_test.go using NATS.Server.Internal.TimeHashWheel; namespace NATS.Server.Tests.Internal.TimeHashWheel; public class HashWheelTests { /// /// Helper to produce nanosecond timestamps relative to a base, matching /// the Go test pattern of now.Add(N * time.Second).UnixNano(). /// 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 { [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(); 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(); 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 { [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(); 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 { [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); } } } }