diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.Entry.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.Entry.cs new file mode 100644 index 00000000000..b04f42ea084 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.Entry.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; + +namespace Microsoft.CodeAnalysis.Razor.Utilities; + +internal sealed partial class MemoryCache where TKey : notnull + where TValue : class +{ + private sealed class Entry(TValue value) + { + private long _lastAccessTicks = DateTime.UtcNow.Ticks; + + public DateTime LastAccess => new(Volatile.Read(ref _lastAccessTicks)); + public TValue Value => value; + + public void UpdateLastAccess() + { + Volatile.Write(ref _lastAccessTicks, DateTime.UtcNow.Ticks); + } + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.TestAccessor.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.TestAccessor.cs new file mode 100644 index 00000000000..d6a9e048e87 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.TestAccessor.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.CodeAnalysis.Razor.Utilities; + +internal partial class MemoryCache + where TKey : notnull + where TValue : class +{ + internal TestAccessor GetTestAccessor() => new(this); + + internal struct TestAccessor(MemoryCache instance) + { + public event Action Compacted + { + add => instance._compactedHandler += value; + remove => instance._compactedHandler -= value; + } + + public readonly bool TryGetLastAccess(TKey key, out DateTime result) + { + if (instance._map.TryGetValue(key, out var value)) + { + result = value.LastAccess; + return true; + } + + result = default; + return false; + } + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.cs index 6679d96ee80..1e15c439d6d 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/MemoryCache`2.cs @@ -5,37 +5,45 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; namespace Microsoft.CodeAnalysis.Razor.Utilities; -// We've created our own MemoryCache here, ideally we would use the one in Microsoft.Extensions.Caching.Memory, -// but until we update O# that causes an Assembly load problem. -internal class MemoryCache +/// +/// A thread-safe, size-limited cache with approximate LRU (Least Recently Used) +/// eviction policy. When the cache reaches its size limit, it removes approximately +/// half of the least recently used entries. +/// +/// The type of keys in the cache. +/// The type of values in the cache. +/// The maximum number of entries the cache can hold before compaction is triggered. +/// The estimated number of threads that will update the cache concurrently. +internal sealed partial class MemoryCache(int sizeLimit = 50, int concurrencyLevel = 2) where TKey : notnull where TValue : class { - private const int DefaultSizeLimit = 50; - private const int DefaultConcurrencyLevel = 2; + private readonly ConcurrentDictionary _map = new(concurrencyLevel, capacity: sizeLimit); - protected IDictionary _dict; + /// + /// Lock used to synchronize cache compaction operations. This prevents multiple threads + /// from attempting to compact the cache simultaneously while allowing concurrent reads. + /// + private readonly object _compactLock = new(); + private readonly int _sizeLimit = sizeLimit; - private readonly object _compactLock; - private readonly int _sizeLimit; + /// + /// Optional callback invoked after cache compaction completes. Only used by tests. + /// + private Action? _compactedHandler; - public MemoryCache(int sizeLimit = DefaultSizeLimit, int concurrencyLevel = DefaultConcurrencyLevel) + /// + /// Attempts to retrieve a value from the cache and updates its last access time if found. + /// + public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue? result) { - _sizeLimit = sizeLimit; - _dict = new ConcurrentDictionary(concurrencyLevel, capacity: _sizeLimit); - _compactLock = new object(); - } - - public bool TryGetValue(TKey key, [NotNullWhen(returnValue: true)] out TValue? result) - { - if (_dict.TryGetValue(key, out var value)) + if (_map.TryGetValue(key, out var entry)) { - value.LastAccess = DateTime.UtcNow; - result = value.Value; + entry.UpdateLastAccess(); + result = entry.Value; return true; } @@ -43,39 +51,80 @@ public bool TryGetValue(TKey key, [NotNullWhen(returnValue: true)] out TValue? r return false; } + /// + /// Adds or updates a value in the cache. If the cache is at capacity, triggers compaction + /// before adding the new entry. + /// public void Set(TKey key, TValue value) { + CompactIfNeeded(); + + _map[key] = new Entry(value); + } + + /// + /// Removes approximately half of the least recently used entries when the cache reaches capacity. + /// + private void CompactIfNeeded() + { + // Fast path: check size without locking + if (_map.Count < _sizeLimit) + { + return; + } + lock (_compactLock) { - if (_dict.Count >= _sizeLimit) + // Double-check after acquiring lock in case another thread already compacted + if (_map.Count < _sizeLimit) { - Compact(); + return; } - } - _dict[key] = new CacheEntry - { - LastAccess = DateTime.UtcNow, - Value = value, - }; - } + // Create a snapshot with last access times to implement approximate LRU eviction. + // This captures each entry's access time to determine which entries were least recently used. + var orderedItems = _map.ToArray().SelectAndOrderByAsArray( + selector: static x => (x.Key, x.Value.LastAccess), + keySelector: static x => x.LastAccess); - public void Clear() => _dict.Clear(); + var toRemove = Math.Max(_sizeLimit / 2, 1); - protected virtual void Compact() - { - var kvps = _dict.ToArray().OrderBy(x => x.Value.LastAccess).ToArray(); + // Remove up to half of the oldest entries using an atomic remove-then-check pattern. + // This ensures we don't remove entries that were accessed after our snapshot was taken. + foreach (var (itemKey, itemLastAccess) in orderedItems) + { + // Atomic remove-then-check pattern eliminates race conditions + // Note: If TryRemove fails, another thread already removed this entry. + if (_map.TryRemove(itemKey, out var removedEntry)) + { + if (removedEntry.LastAccess == itemLastAccess) + { + // Entry was still old when removed - successful eviction + toRemove--; - for (var i = 0; i < _sizeLimit / 2; i++) - { - _dict.Remove(kvps[i].Key); + // Stop early if we've removed enough entries + if (toRemove == 0) + { + break; + } + } + else + { + // Entry was accessed after snapshot - try to restore it + // If TryAdd fails, another thread already added a new entry with this key, + // which is acceptable - we preserve the hot entry's data either way + _map.TryAdd(itemKey, removedEntry); + } + } + } + + _compactedHandler?.Invoke(); } } - protected class CacheEntry - { - public required TValue Value { get; init; } - - public required DateTime LastAccess { get; set; } - } + /// + /// Removes all entries from the cache. + /// + public void Clear() + => _map.Clear(); } diff --git a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCachePerfTest.cs b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCachePerfTest.cs new file mode 100644 index 00000000000..223f224ff13 --- /dev/null +++ b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCachePerfTest.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.CodeAnalysis.Razor.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.Razor.Workspaces.Test.Utilities; + +[CollectionDefinition(nameof(MemoryCachePerfTest), DisableParallelization = false)] +[Collection(nameof(MemoryCachePerfTest))] +public class MemoryCachePerfTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) +{ + [Fact] + public async Task HighFrequencyAccess_MaintainsPerformance() + { + var cache = new MemoryCache>(); + const string Key = "hot-key"; + cache.Set(Key, [1, 2, 3]); + + const int AccessCount = 10_000; + var stopwatch = Stopwatch.StartNew(); + + var tasks = Enumerable.Range(0, Environment.ProcessorCount) + .Select(x => Task.Run(() => + { + for (var i = 0; i < AccessCount / Environment.ProcessorCount; i++) + { + _ = cache.TryGetValue(Key, out _); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + stopwatch.Stop(); + + // Should complete reasonably quickly (adjust threshold as needed) + Assert.True(stopwatch.ElapsedMilliseconds < 1000, + $"High-frequency access took too long: {stopwatch.ElapsedMilliseconds}ms"); + } +} diff --git a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCacheTest.cs b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCacheTest.cs index df46bed8013..0fded76daab 100644 --- a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCacheTest.cs +++ b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Utilities/MemoryCacheTest.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,11 +16,13 @@ namespace Microsoft.CodeAnalysis.Razor.Utilities; public class MemoryCacheTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) { + private static string GetNewKey() => Guid.NewGuid().ToString(); + [Fact] public async Task ConcurrentSets_DoesNotThrow() { // Arrange - var cache = new TestMemoryCache(); + var cache = new MemoryCache>(); var entries = Enumerable.Range(0, 500); var repeatCount = 4; @@ -30,7 +32,7 @@ public async Task ConcurrentSets_DoesNotThrow() { // 2 is an arbitrarily low number, we're just trying to emulate concurrency await Task.Delay(2); - cache.Set(entry.ToString(CultureInfo.InvariantCulture), Array.Empty()); + cache.Set(entry.ToString(), value: []); }); // Act & Assert @@ -38,98 +40,516 @@ public async Task ConcurrentSets_DoesNotThrow() } [Fact] - public void LastAccessIsUpdated() + public void LastAccessTime_IsUpdatedOnGet() { - var cache = new TestMemoryCache(); - var key = GetKey(); - var value = new List(); + var cache = new MemoryCache>(); + var cacheAccessor = cache.GetTestAccessor(); + var key = GetNewKey(); - cache.Set(key, value); - var oldAccessTime = cache.GetAccessTime(key); + cache.Set(key, value: []); + Assert.True(cacheAccessor.TryGetLastAccess(key, out var oldAccessTime)); Thread.Sleep(millisecondsTimeout: 10); - cache.TryGetValue(key, out _); - var newAccessTime = cache.GetAccessTime(key); + Assert.True(cache.TryGetValue(key, out _)); + Assert.True(cacheAccessor.TryGetLastAccess(key, out var newAccessTime)); Assert.True(newAccessTime > oldAccessTime, "New AccessTime should be greater than old"); } [Fact] - public void BasicAdd() + public void SetAndGet_WithValidKeyAndValue_ReturnsExpectedValue() { - var cache = new TestMemoryCache(); - var key = GetKey(); - var value = new List { 1, 2, 3 }; + var cache = new MemoryCache>(); + var key = GetNewKey(); + var value = new List { 1, 2, 3 }; cache.Set(key, value); - cache.TryGetValue(key, out var result); - + Assert.True(cache.TryGetValue(key, out var result)); Assert.Same(value, result); } [Fact] - public void Compaction() + public void Compaction_TriggersWhenSizeLimitReached() { - var cache = new TestMemoryCache(); - var sizeLimit = TestMemoryCache.SizeLimit; + const int SizeLimit = 10; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); - for (var i = 0; i < sizeLimit; i++) + var wasCompacted = false; + cacheAccessor.Compacted += () => wasCompacted = true; + + for (var i = 0; i < SizeLimit; i++) { - var key = GetKey(); - var value = new List { (uint)i }; - cache.Set(key, value); - Assert.False(cache._wasCompacted, "It got compacted early."); + cache.Set(GetNewKey(), [i]); + Assert.False(wasCompacted, "It got compacted early."); } - cache.Set(GetKey(), new List { (uint)sizeLimit + 1 }); - Assert.True(cache._wasCompacted, "Compaction is not happening"); + cache.Set(GetNewKey(), [SizeLimit]); + Assert.True(wasCompacted, "Compaction is not happening"); } [Fact] - public void MissingKey() + public void TryGetValue_WithMissingKey_ReturnsFalse() { - var cache = new TestMemoryCache(); - var key = GetKey(); - - cache.TryGetValue(key, out var value); + var cache = new MemoryCache>(); + var key = GetNewKey(); - Assert.Null(value); + Assert.False(cache.TryGetValue(key, out _)); } [Fact] - public void NullKey() + public void NullKey_ThrowsArgumentNullException() { - var cache = new TestMemoryCache(); + var cache = new MemoryCache>(); Assert.Throws(() => cache.TryGetValue(key: null!, out var result)); + Assert.Throws(() => cache.Set(key: null!, [])); } - private static string GetKey() + [Fact] + public void CompactionWithSizeLimitOne_BehavesCorrectly() { - return Guid.NewGuid().ToString(); + const int SizeLimit = 1; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); + + var compactionCount = 0; + cacheAccessor.Compacted += () => compactionCount++; + + // First entry should not trigger compaction + cache.Set("key1", [1]); + Assert.Equal(0, compactionCount); + + // Second entry should trigger compaction (removes at least 1 entry) + cache.Set("key2", [2]); + Assert.Equal(1, compactionCount); + + // Only one entry should remain + var keys = new[] { "key1", "key2" }; + Assert.Single(keys.Where(key => cache.TryGetValue(key, out _))); } - private class TestMemoryCache : MemoryCache> + [Fact] + public async Task ConcurrentSetsWithSameKey_LastWriterWins() { - public static int SizeLimit = 10; - public bool _wasCompacted = false; + var cache = new MemoryCache>(); + const string Key = "same-key"; + const int TaskCount = 100; + + var tasks = Enumerable.Range(0, TaskCount) + .Select(i => Task.Run(() => cache.Set(Key, [i]))) + .ToArray(); + + await Task.WhenAll(tasks); + + // Should have exactly one entry with some value 0-99 + Assert.True(cache.TryGetValue(Key, out var result)); + Assert.Single(result); + Assert.True(result[0] >= 0 && result[0] < TaskCount); + } - public TestMemoryCache() - : base(SizeLimit) + [Fact] + public async Task RapidCompactionTriggers_DoesNotCauseExcessiveCompaction() + { + const int SizeLimit = 10; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); + + // Fill to capacity + for (var i = 0; i < SizeLimit; i++) + { + cache.Set($"initial-{i}", [i]); + } + + var compactionCount = 0; + cacheAccessor.Compacted += () => Interlocked.Increment(ref compactionCount); + + // Rapidly trigger many compactions + var tasks = Enumerable.Range(0, 30) + .Select(i => Task.Run(() => cache.Set($"rapid-{i}", [i]))) + .ToArray(); + + await Task.WhenAll(tasks); + + // Compaction should happen, but not excessively due to double-checked locking + Assert.True(compactionCount > 0); + Assert.True(compactionCount < 10, $"Too many compactions: {compactionCount}"); + } + + [Fact] + public async Task AccessingEntryDuringRemoval_IsThreadSafe() + { + const int SizeLimit = 3; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); + + // Fill cache + var keys = new List(); + for (var i = 0; i < SizeLimit; i++) { + var key = $"key-{i}"; + keys.Add(key); + cache.Set(key, [i]); } - public DateTime GetAccessTime(string key) + using var compactionStarted = new ManualResetEventSlim(); + using var continueCompaction = new ManualResetEventSlim(); + + cacheAccessor.Compacted += () => + { + compactionStarted.Set(); + continueCompaction.Wait(TimeSpan.FromSeconds(1)); + }; + + // Start compaction + var compactionTask = Task.Run(() => cache.Set("trigger", [999])); + + // Wait for compaction to start, then hammer the first key + compactionStarted.Wait(TimeSpan.FromSeconds(1)); + + var accessTask = Task.Run(() => { - return _dict[key].LastAccess; + for (var i = 0; i < 1000; i++) + { + _ = cache.TryGetValue(keys[0], out _); + } + }); + + continueCompaction.Set(); + + // Should complete without exceptions + await Task.WhenAll(compactionTask, accessTask); + } + + [Fact] + public void LargeValues_DoNotCauseIssues() + { + var cache = new MemoryCache>(); + var largeValue = Enumerable.Range(0, 100_000).ToList(); + + cache.Set("large", largeValue); + + Assert.True(cache.TryGetValue("large", out var result)); + Assert.Same(largeValue, result); + Assert.Equal(100_000, result.Count); + } + + [Fact] + public async Task ConcurrentAccess_DuringCompaction_DoesNotLoseHotEntries() + { + const int SizeLimit = 10; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); + + // Fill cache to trigger compaction + var keys = new List(); + for (var i = 0; i < SizeLimit; i++) + { + var key = GetNewKey(); + keys.Add(key); + cache.Set(key, [i]); } - protected override void Compact() + // Set up compaction monitoring + using var compactionStarted = new ManualResetEventSlim(); + using var continueCompaction = new ManualResetEventSlim(); + + cacheAccessor.Compacted += () => + { + compactionStarted.Set(); + continueCompaction.Wait(); // Block compaction + }; + + // Start compaction in background + var compactionTask = Task.Run(() => cache.Set("trigger-compaction", [999])); + + // Wait for compaction to start, then access entries concurrently + compactionStarted.Wait(); + + var accessTasks = keys.Select(key => Task.Run(() => + { + for (var i = 0; i < 10; i++) + { + _ = cache.TryGetValue(key, out _); + Thread.Sleep(1); // Small delay to increase race condition chances + } + })).ToArray(); + + // Allow compaction to complete + continueCompaction.Set(); + + await Task.WhenAll([compactionTask, .. accessTasks]); + + // Verify frequently accessed entries weren't removed + var survivingCount = keys.Count(key => cache.TryGetValue(key, out _)); + Assert.True(survivingCount > 0, "Some frequently accessed entries should survive compaction"); + } + + [Fact] + public async Task ConcurrentSets_DuringCompaction_AreThreadSafe() + { + const int SizeLimit = 5; + var cache = new MemoryCache>(SizeLimit); + + using var cancellationSource = new CancellationTokenSource(); + + // Fill cache to near capacity + for (var i = 0; i < SizeLimit - 1; i++) { - _wasCompacted = true; - base.Compact(); + cache.Set($"initial-{i}", [i]); } + + // Start continuous Set operations + var setTask = Task.Run(async () => + { + var counter = 0; + try + { + while (!cancellationSource.IsCancellationRequested) + { + cache.Set($"concurrent-{counter}", [counter]); + counter++; + await Task.Delay(1, cancellationSource.Token); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation token is canceled + } + }); + + // Trigger compaction + var compactionTask = Task.Run(() => + { + cache.Set("trigger-compaction", [999]); // This should trigger compaction + }); + + await Task.Delay(50); // Let operations run concurrently + cancellationSource.Cancel(); + + await Task.WhenAll(setTask, compactionTask); + + // Verify cache is still functional + cache.Set("final-test", [123]); + Assert.True(cache.TryGetValue("final-test", out var result)); + Assert.Equal([123], result); + } + + [Fact] + public async Task LastAccessTime_UnderHighContention_IsReasonablyAccurate() + { + var cache = new MemoryCache>(); + var cacheAccessor = cache.GetTestAccessor(); + var key = GetNewKey(); + + cache.Set(key, [1]); + + var accessTimes = new ConcurrentBag(); + var accessCount = 100; + + // Concurrent access from multiple threads + var tasks = Enumerable.Range(0, accessCount) + .Select(x => Task.Run(() => + { + _ = cache.TryGetValue(key, out _); + + if (cacheAccessor.TryGetLastAccess(key, out var accessTime)) + { + accessTimes.Add(accessTime); + } + })); + + await Task.WhenAll(tasks); + + // Verify we got reasonable access times (no default DateTime values) + Assert.False(accessTimes.IsEmpty); + Assert.All(accessTimes, time => Assert.True(time > DateTime.MinValue)); + + // Verify the final access time is recent + Assert.True(cacheAccessor.TryGetLastAccess(key, out var finalTime)); + Assert.True(DateTime.UtcNow - finalTime < TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task LRUEviction_WithConcurrentAccess_BehavesReasonably() + { + const int SizeLimit = 5; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); + + var hotAccessEstablished = new TaskCompletionSource(); + var coldEntryAdded = false; + + // Add initial entries + var initialKeys = new string[SizeLimit]; + + for (var i = 0; i < SizeLimit; i++) + { + var key = $"initial-{i}"; + initialKeys[i] = key; + cache.Set(key, [i]); + } + + // Continuously access first couple entries to make them "hot" + var hotKeys = initialKeys.Take(2).ToArray(); + var keepHotTask = Task.Run(async () => + { + try + { + // Establish hot key access + for (var i = 0; i < 10; i++) + { + await AccessHotKeysAsync(hotKeys, cache); + } + + hotAccessEstablished.TrySetResult(true); + + // Continue accessing hot keys while waiting for cold entry to be added + while (!coldEntryAdded) + { + await AccessHotKeysAsync(hotKeys, cache); + } + + // Continue accessing hot keys until finished. + for (var i = 0; i < 100; i++) + { + await AccessHotKeysAsync(hotKeys, cache); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation token is canceled + } + + async Task AccessHotKeysAsync(string[] hotKeys, MemoryCache> cache) + { + foreach (var key in hotKeys) + { + _ = cache.TryGetValue(key, out _); + } + + await Task.Delay(1, DisposalToken); + } + }); + + // Trigger compaction + await hotAccessEstablished.Task; + cache.Set("trigger-compaction", [999]); + + // Signal that the cold entry was added + coldEntryAdded = true; + + await keepHotTask; + + // Verify hot entries are more likely to survive + var hotSurvivalCount = hotKeys.Count(key => cache.TryGetValue(key, out _)); + var coldSurvivalCount = initialKeys.Skip(2).Count(key => cache.TryGetValue(key, out _)); + + // Hot entries should have better survival rate + Assert.True(hotSurvivalCount >= coldSurvivalCount, + $"Hot entries ({hotSurvivalCount}) should survive at least as well as cold entries ({coldSurvivalCount})"); + } + + [Fact] + public async Task CompactionLocking_PreventsMultipleSimultaneousCompactions() + { + const int SizeLimit = 5; + var cache = new MemoryCache>(SizeLimit); + var cacheAccessor = cache.GetTestAccessor(); + + // Fill to capacity + for (var i = 0; i < SizeLimit; i++) + { + cache.Set($"initial-{i}", [i]); + } + + var compactionCount = 0; + cacheAccessor.Compacted += () => Interlocked.Increment(ref compactionCount); + + // Trigger multiple compactions simultaneously + var compactionTasks = Enumerable.Range(0, 10) + .Select(i => Task.Run(() => cache.Set($"trigger-{i}", [i]))) + .ToArray(); + + await Task.WhenAll(compactionTasks); + + // Should have compacted at least once, but likely not 10 times due to double-checked locking + Assert.True(compactionCount >= 1, "At least one compaction should have occurred"); + Assert.True(compactionCount < 10, "Double-checked locking should prevent excessive compactions"); + } + + [Fact] + public async Task Clear_WhileConcurrentOperations_IsThreadSafe() + { + var cache = new MemoryCache>(); + var testDuration = TimeSpan.FromMilliseconds(100); + using var cancellationSource = new CancellationTokenSource(testDuration); + + // Continuous operations + var tasks = new[] + { + // Continuous sets + Task.Run(async () => + { + var counter = 0; + try + { + while (!cancellationSource.IsCancellationRequested) + { + cache.Set($"key-{counter}", [counter]); + counter++; + await Task.Delay(1, cancellationSource.Token); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation token times out + } + }), + + // Continuous gets + Task.Run(async () => + { + try + { + while (!cancellationSource.IsCancellationRequested) + { + _ = cache.TryGetValue("key-0", out _); + await Task.Delay(1, cancellationSource.Token); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation token times out + } + }), + + // Periodic clears + Task.Run(async () => + { + try + { + while (!cancellationSource.IsCancellationRequested) + { + await Task.Delay(10, cancellationSource.Token); + cache.Clear(); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation token times out + } + }) + }; + + // Should complete without unhandled exceptions + await Task.WhenAll(tasks); + + // Cache should still be functional after all the chaos + cache.Set("final-test", [123]); + Assert.True(cache.TryGetValue("final-test", out var result)); + Assert.Equal([123], result); } }