From eab8326a091c62d8a2572c26311db6ea995a54c2 Mon Sep 17 00:00:00 2001 From: Johan Ketels Date: Mon, 7 Sep 2026 09:33:24 +0200 Subject: [PATCH 1/3] fix: create the HzMemoryCache ActivitySource once instead of per call HzActivities.Source was an expression-bodied property, so every cache operation and every cleanup-timer tick constructed a new ActivitySource. Each instance registers itself in the runtime's global source list and is never disposed, so the list grows for the lifetime of the process. Since .NET 9 that list is a copy-on-write array: each registration allocates a new array and copies all previous entries. In admin-backend this showed up as allocation rate and gen2 GC rate climbing linearly with uptime while idle (0.3 -> 7 MB/s over 2.5 days at ~2 creations/s from the 1 s cleanup timer alone), with memory and CPU following until the next deploy. A single static instance is the documented usage pattern for ActivitySource and removes the growth entirely. Co-Authored-By: Claude Fable 5.1 --- HzMemoryCache/Diagnostics/HzActivities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HzMemoryCache/Diagnostics/HzActivities.cs b/HzMemoryCache/Diagnostics/HzActivities.cs index 19291bb..404eb5b 100644 --- a/HzMemoryCache/Diagnostics/HzActivities.cs +++ b/HzMemoryCache/Diagnostics/HzActivities.cs @@ -6,7 +6,7 @@ namespace HzCache.Diagnostics public static class HzActivities { public const string HzCacheActivitySourceName = "HzMemoryCache"; - public static ActivitySource? Source => new(HzCacheActivitySourceName); + public static readonly ActivitySource Source = new(HzCacheActivitySourceName); public static class Names { From e479aea8a6944c19502a8be96328470f84f9bced Mon Sep 17 00:00:00 2001 From: Johan Ketels Date: Mon, 7 Sep 2026 09:37:28 +0200 Subject: [PATCH 2/3] test: guard against per-call ActivitySource construction Asserts that HzActivities.Source is a single instance and that a burst of cache operations plus cleanup-timer ticks registers no new ActivitySource, observed through an ActivityListener's ShouldListenTo callback. Both tests fail against the previous expression-bodied property. Co-Authored-By: Claude Fable 5.1 --- UnitTests/DiagnosticsTests.cs | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 UnitTests/DiagnosticsTests.cs diff --git a/UnitTests/DiagnosticsTests.cs b/UnitTests/DiagnosticsTests.cs new file mode 100644 index 0000000..ad7704f --- /dev/null +++ b/UnitTests/DiagnosticsTests.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using HzCache; +using HzCache.Diagnostics; + +namespace UnitTests +{ + [TestClass] + public class DiagnosticsTests + { + [TestMethod] + public void SourceIsASingleInstance() + { + Assert.AreSame(HzActivities.Source, HzActivities.Source); + } + + [TestMethod] + public async Task CacheOperationsDoNotCreateNewActivitySources() + { + // Every ActivitySource constructor registers the instance in the runtime's global list and + // invokes ShouldListenTo on all listeners, so counting those callbacks after registration + // detects any per-call construction of the source. + var created = 0; + var armed = false; + using var listener = new ActivityListener + { + ShouldListenTo = source => + { + if (armed && source.Name == HzActivities.HzCacheActivitySourceName) + Interlocked.Increment(ref created); + return false; + } + }; + ActivitySource.AddActivityListener(listener); + armed = true; + + using var cache = new HzMemoryCache(new HzCacheOptions { cleanupJobInterval = 20, notificationType = NotificationType.Sync }); + for (var i = 0; i < 100; i++) + { + cache.GetOrSet($"key{i}", _ => new MockObject(i), TimeSpan.FromMinutes(1)); + cache.Get($"key{i}"); + } + cache.Remove("key0"); + cache.RemoveByPattern("key1*"); + await Task.Delay(200); // several cleanup-timer ticks + + Assert.AreEqual(0, created); + } + } +} From 5276cc821079b3db8325416b5c54edb34cb6e1d7 Mon Sep 17 00:00:00 2001 From: Johan Ketels Date: Mon, 7 Sep 2026 11:09:13 +0200 Subject: [PATCH 3/3] review: keep Source a property; make the test independent of ordering Expose the single instance through a property backed by a private static field, so consumers compiled against get_Source() keep working. The test now captures the expected instance before registering the listener and counts any other source with the same name, which removes the cross-thread flag and the dependence on which test touches Source first. Co-Authored-By: Claude Fable 5.1 --- HzMemoryCache/Diagnostics/HzActivities.cs | 5 +++-- UnitTests/DiagnosticsTests.cs | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/HzMemoryCache/Diagnostics/HzActivities.cs b/HzMemoryCache/Diagnostics/HzActivities.cs index 404eb5b..b1f0b4e 100644 --- a/HzMemoryCache/Diagnostics/HzActivities.cs +++ b/HzMemoryCache/Diagnostics/HzActivities.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; namespace HzCache.Diagnostics @@ -6,7 +6,8 @@ namespace HzCache.Diagnostics public static class HzActivities { public const string HzCacheActivitySourceName = "HzMemoryCache"; - public static readonly ActivitySource Source = new(HzCacheActivitySourceName); + private static readonly ActivitySource source = new(HzCacheActivitySourceName); + public static ActivitySource Source => source; public static class Names { diff --git a/UnitTests/DiagnosticsTests.cs b/UnitTests/DiagnosticsTests.cs index ad7704f..2ccc4c3 100644 --- a/UnitTests/DiagnosticsTests.cs +++ b/UnitTests/DiagnosticsTests.cs @@ -17,21 +17,21 @@ public void SourceIsASingleInstance() public async Task CacheOperationsDoNotCreateNewActivitySources() { // Every ActivitySource constructor registers the instance in the runtime's global list and - // invokes ShouldListenTo on all listeners, so counting those callbacks after registration - // detects any per-call construction of the source. + // invokes ShouldListenTo on all listeners, so any source with our name other than the one + // expected instance is a per-call construction. Touch Source first so the static instance + // exists before the listener is registered, regardless of test ordering. + var expected = HzActivities.Source; var created = 0; - var armed = false; using var listener = new ActivityListener { ShouldListenTo = source => { - if (armed && source.Name == HzActivities.HzCacheActivitySourceName) + if (source.Name == HzActivities.HzCacheActivitySourceName && !ReferenceEquals(source, expected)) Interlocked.Increment(ref created); return false; } }; ActivitySource.AddActivityListener(listener); - armed = true; using var cache = new HzMemoryCache(new HzCacheOptions { cleanupJobInterval = 20, notificationType = NotificationType.Sync }); for (var i = 0; i < 100; i++)