diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs index 69a560d7..98978f3f 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs @@ -272,6 +272,20 @@ await Threaded.Run(threads, () => await RunIntegrityCheckAsync(lfu, iteration); } + [Theory] + [Repeat(soakIterations)] + public async Task WhenConcurrentTryGetAddOrUpdateAndTrimCacheEndsInConsistentState(int iteration) + { + const long trimAfter = 100; + string value = "x"; + + var trimmingCache = new TrimmingLfuCache(maxItems: 1_000_000, trimAfter: trimAfter); + Parallel.For(0, 5000, i => trimmingCache.AddWithTrim("x" + i, value)); + trimmingCache.AddWithTrim("y", value); + + await RunIntegrityCheckAsync(trimmingCache._cache, iteration); + } + #if NET9_0_OR_GREATER [Theory] [Repeat(soakIterations)] diff --git a/BitFaster.Caching.UnitTests/Lfu/TrimmingLfuCache.cs b/BitFaster.Caching.UnitTests/Lfu/TrimmingLfuCache.cs new file mode 100644 index 00000000..f06c4fef --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lfu/TrimmingLfuCache.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using BitFaster.Caching.Lfu; +using BitFaster.Caching.Scheduler; + +namespace BitFaster.Caching.UnitTests.Lfu +{ + // simple wrapper around ConcurrentLfu to trim the cache after a certain number of items have been added: repros reported bug + internal class TrimmingLfuCache + { + private const int MinTrimBatch = 16; + private const int MaxTrimIterations = 64; + + private readonly long trimAfter; + + private int trimInProgress; + + public readonly ConcurrentLfu _cache; + + internal TrimmingLfuCache(int maxItems, long trimAfter) + { + this.trimAfter = trimAfter; + + _cache = new ConcurrentLfu( + Environment.ProcessorCount, + maxItems , + new ForegroundScheduler(), + EqualityComparer.Default); + } + + public void AddWithTrim(string key, string value) + { + if (_cache.TryGet(key, out _)) + { + return; + } + + _cache.AddOrUpdate(key, value); + + this.Trim(); + } + + private void Trim() + { + if (Interlocked.CompareExchange(ref trimInProgress, 1, 0) != 0) + { + return; + } + + try + { + if (_cache.Policy.Eviction.HasValue) + { + IBoundedPolicy eviction = _cache.Policy.Eviction.Value!; + int iterations = 0; + + long currentCount = _cache.Count; + while (_cache.Count > trimAfter && currentCount > 0 && iterations++ < MaxTrimIterations) + { + long over = currentCount - trimAfter; + int toTrim = (int)Math.Min(currentCount, Math.Max(MinTrimBatch, (over) + 1)); + eviction.Trim(toTrim); + currentCount = _cache.Count; + } + } + } + finally + { + Volatile.Write(ref trimInProgress, 0); + } + } + } +} diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 923cb8fa..9bb388a3 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -200,15 +200,15 @@ private void Trim(int itemCount, ItemRemovedReason reason) TakeCandidatesInLruOrder(this.probationLru, candidates, itemCount); TakeCandidatesInLruOrder(this.protectedLru, candidates, itemCount); TakeCandidatesInLruOrder(this.windowLru, candidates, itemCount); - } #if NET6_0_OR_GREATER foreach (var candidate in CollectionsMarshal.AsSpan(candidates)) #else - foreach (var candidate in candidates) + foreach (var candidate in candidates) #endif - { - Evict(candidate, reason); + { + Evict(candidate, reason); + } } }