From 661d8956e189362dae8c573819f54d66cd347e0f Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 9 Jan 2026 09:45:59 -0800 Subject: [PATCH 01/12] part 1 --- BitFaster.Caching/Lfu/ConcurrentLfu.cs | 14 +++-- BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 29 ++++++++-- BitFaster.Caching/Lfu/ConcurrentTLfu.cs | 12 +++-- BitFaster.Caching/Lfu/EventPolicy.cs | 61 ++++++++++++++++++++++ BitFaster.Caching/Lfu/IEventPolicy.cs | 37 +++++++++++++ BitFaster.Caching/Lfu/NoEventPolicy.cs | 55 +++++++++++++++++++ BitFaster.Caching/Lfu/NodePolicy.cs | 15 +++--- BitFaster.Caching/Lfu/TimerWheel.cs | 10 ++-- 8 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 BitFaster.Caching/Lfu/EventPolicy.cs create mode 100644 BitFaster.Caching/Lfu/IEventPolicy.cs create mode 100644 BitFaster.Caching/Lfu/NoEventPolicy.cs diff --git a/BitFaster.Caching/Lfu/ConcurrentLfu.cs b/BitFaster.Caching/Lfu/ConcurrentLfu.cs index 9038b593..922ad674 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfu.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfu.cs @@ -36,7 +36,7 @@ public sealed class ConcurrentLfu : ICacheExt, IAsyncCacheExt, where K : notnull { // Note: for performance reasons this is a mutable struct, it cannot be readonly. - private ConcurrentLfuCore, AccessOrderPolicy> core; + private ConcurrentLfuCore, AccessOrderPolicy>, EventPolicy> core; /// /// The default buffer size. @@ -49,7 +49,9 @@ public sealed class ConcurrentLfu : ICacheExt, IAsyncCacheExt, /// The capacity. public ConcurrentLfu(int capacity) { - this.core = new(Defaults.ConcurrencyLevel, capacity, new ThreadPoolScheduler(), EqualityComparer.Default, () => this.DrainBuffers(), default); + EventPolicy eventPolicy = default; + eventPolicy.SetEventSource(this); + this.core = new(Defaults.ConcurrencyLevel, capacity, new ThreadPoolScheduler(), EqualityComparer.Default, () => this.DrainBuffers(), default, eventPolicy); } /// @@ -61,10 +63,12 @@ public ConcurrentLfu(int capacity) /// The equality comparer. public ConcurrentLfu(int concurrencyLevel, int capacity, IScheduler scheduler, IEqualityComparer comparer) { - this.core = new(concurrencyLevel, capacity, scheduler, comparer, () => this.DrainBuffers(), default); + EventPolicy eventPolicy = default; + eventPolicy.SetEventSource(this); + this.core = new(concurrencyLevel, capacity, scheduler, comparer, () => this.DrainBuffers(), default, eventPolicy); } - internal ConcurrentLfuCore, AccessOrderPolicy> Core => core; + internal ConcurrentLfuCore, AccessOrderPolicy>, EventPolicy> Core => core; // structs cannot declare self referencing lambda functions, therefore pass this in from the ctor private void DrainBuffers() @@ -79,7 +83,7 @@ private void DrainBuffers() public Optional Metrics => core.Metrics; /// - public Optional> Events => Optional>.None(); + public Optional> Events => new(core.eventPolicy); /// public CachePolicy Policy => core.Policy; diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 0e4a3c3c..d5ab530c 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -39,10 +39,11 @@ namespace BitFaster.Caching.Lfu /// Based on the Caffeine library by ben.manes@gmail.com (Ben Manes). /// https://github.com/ben-manes/caffeine - internal struct ConcurrentLfuCore : IBoundedPolicy + internal struct ConcurrentLfuCore : IBoundedPolicy where K : notnull where N : LfuNode - where P : struct, INodePolicy + where P : struct, INodePolicy + where E : struct, IEventPolicy { private const int MaxWriteBufferRetries = 64; @@ -78,7 +79,16 @@ internal struct ConcurrentLfuCore : IBoundedPolicy internal P policy; - public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler scheduler, IEqualityComparer comparer, Action drainBuffers, P policy) + /// + /// The event policy. + /// + /// + /// Since E is a struct, making it readonly will force the runtime to make defensive copies + /// if mutate methods are called. Therefore, field must be mutable to maintain count. + /// + internal E eventPolicy; + + public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler scheduler, IEqualityComparer comparer, Action drainBuffers, P policy, E eventPolicy) { if (capacity < 3) Throw.ArgOutOfRange(nameof(capacity)); @@ -108,6 +118,8 @@ public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler schedule this.drainBuffers = drainBuffers; this.policy = policy; + + this.eventPolicy = eventPolicy; } // No lock count: https://arbel.net/2013/02/03/best-practices-for-using-concurrentdictionary/ @@ -373,6 +385,10 @@ public bool TryUpdate(K key, V value) { if (!node.WasRemoved) { + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + V oldValue = node.Value; +#endif node.Value = value; // It's ok for this to be lossy, since the node is already tracked @@ -380,6 +396,11 @@ public bool TryUpdate(K key, V value) this.writeBuffer.TryAdd(node); TryScheduleDrain(); this.policy.OnWrite(node); + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + this.eventPolicy.OnItemUpdated(key, oldValue, value); +#endif return true; } } @@ -637,6 +658,7 @@ private void OnWrite(N node) { // if a write is in the buffer and is then removed in the buffer, it will enter OnWrite twice. // we mark as deleted to avoid double counting/disposing it + this.eventPolicy.OnItemRemoved(node.Key, node.Value, ItemRemovedReason.Removed); this.metrics.evictedCount++; Disposer.Dispose(node.Value); node.WasDeleted = true; @@ -839,6 +861,7 @@ internal void Evict(LfuNode evictee) ((ICollection>)this.dictionary).Remove(kvp); #endif evictee.list?.Remove(evictee); + this.eventPolicy.OnItemRemoved(evictee.Key, evictee.Value, ItemRemovedReason.Evicted); Disposer.Dispose(evictee.Value); this.metrics.evictedCount++; diff --git a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs index 439fddf1..db574560 100644 --- a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs +++ b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs @@ -13,16 +13,20 @@ internal sealed class ConcurrentTLfu : ICacheExt, IAsyncCacheExt, ExpireAfterPolicy> core; + private ConcurrentLfuCore, ExpireAfterPolicy>, EventPolicy> core; public ConcurrentTLfu(int capacity, IExpiryCalculator expiryCalculator) { - this.core = new(Defaults.ConcurrencyLevel, capacity, new ThreadPoolScheduler(), EqualityComparer.Default, () => this.DrainBuffers(), new(expiryCalculator)); + EventPolicy eventPolicy = default; + eventPolicy.SetEventSource(this); + this.core = new(Defaults.ConcurrencyLevel, capacity, new ThreadPoolScheduler(), EqualityComparer.Default, () => this.DrainBuffers(), new(expiryCalculator), eventPolicy); } public ConcurrentTLfu(int concurrencyLevel, int capacity, IScheduler scheduler, IEqualityComparer comparer, IExpiryCalculator expiryCalculator) { - this.core = new(concurrencyLevel, capacity, scheduler, comparer, () => this.DrainBuffers(), new(expiryCalculator)); + EventPolicy eventPolicy = default; + eventPolicy.SetEventSource(this); + this.core = new(concurrencyLevel, capacity, scheduler, comparer, () => this.DrainBuffers(), new(expiryCalculator), eventPolicy); } // structs cannot declare self referencing lambda functions, therefore pass this in from the ctor @@ -38,7 +42,7 @@ private void DrainBuffers() public Optional Metrics => core.Metrics; /// - public Optional> Events => Optional>.None(); + public Optional> Events => new(core.eventPolicy); /// public CachePolicy Policy => CreatePolicy(); diff --git a/BitFaster.Caching/Lfu/EventPolicy.cs b/BitFaster.Caching/Lfu/EventPolicy.cs new file mode 100644 index 00000000..922d3ad4 --- /dev/null +++ b/BitFaster.Caching/Lfu/EventPolicy.cs @@ -0,0 +1,61 @@ +using System; +using System.Diagnostics; +using BitFaster.Caching.Counters; + +namespace BitFaster.Caching.Lfu +{ + /// + /// Represents an event policy with events. + /// + /// The type of the Key + /// The type of the value + [DebuggerDisplay("Upd = {Updated}, Evict = {Evicted}")] + public struct EventPolicy : IEventPolicy + where K : notnull + { + private Counter evictedCount; + private Counter updatedCount; + private object eventSource; + + /// + public event EventHandler> ItemRemoved; + + /// + public event EventHandler> ItemUpdated; + + /// + public long Evicted => this.evictedCount.Count(); + + /// + public long Updated => this.updatedCount.Count(); + + /// + public void OnItemRemoved(K key, V value, ItemRemovedReason reason) + { + if (reason == ItemRemovedReason.Evicted) + { + this.evictedCount.Increment(); + } + + // passing 'this' as source boxes the struct, and is anyway the wrong object + this.ItemRemoved?.Invoke(this.eventSource, new ItemRemovedEventArgs(key, value, reason)); + } + + /// + public void OnItemUpdated(K key, V oldValue, V newValue) + { + this.updatedCount.Increment(); + + // passing 'this' as source boxes the struct, and is anyway the wrong object + this.ItemUpdated?.Invoke(this.eventSource, new ItemUpdatedEventArgs(key, oldValue, newValue)); + } + + /// + public void SetEventSource(object source) + { + this.evictedCount = new Counter(); + this.updatedCount = new Counter(); + this.eventSource = source; + } + } +} diff --git a/BitFaster.Caching/Lfu/IEventPolicy.cs b/BitFaster.Caching/Lfu/IEventPolicy.cs new file mode 100644 index 00000000..238863d3 --- /dev/null +++ b/BitFaster.Caching/Lfu/IEventPolicy.cs @@ -0,0 +1,37 @@ + +namespace BitFaster.Caching.Lfu +{ + /// + /// Represents an event policy. + /// + /// The type of the key. + /// The type of the value. + public interface IEventPolicy : ICacheEvents + where K : notnull + { + /// + /// Register the removal of an item. + /// + /// The key. + /// The value. + /// The reason for removal. + void OnItemRemoved(K key, V value, ItemRemovedReason reason); + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + /// + /// Register the update of an item. + /// + /// The key. + /// The old value. + /// The new value. + void OnItemUpdated(K key, V oldValue, V value) {} +#endif + + /// + /// Set the event source for any events that are fired. + /// + /// The event source. + void SetEventSource(object source); + } +} diff --git a/BitFaster.Caching/Lfu/NoEventPolicy.cs b/BitFaster.Caching/Lfu/NoEventPolicy.cs new file mode 100644 index 00000000..d9e7c190 --- /dev/null +++ b/BitFaster.Caching/Lfu/NoEventPolicy.cs @@ -0,0 +1,55 @@ +using System; +using System.Runtime.CompilerServices; + +namespace BitFaster.Caching.Lfu +{ + /// + /// Represents an event policy that does not have events (is disabled). + /// This enables use of the cache without events where maximum performance is required. + /// + /// The type of the key. + /// The type of the value. + public struct NoEventPolicy : IEventPolicy + where K : notnull + { + /// + public long Updated => 0; + + /// + public long Evicted => 0; + + /// + public event EventHandler> ItemRemoved + { + // no-op, nothing is registered + add { } + remove { } + } + + /// + public event EventHandler> ItemUpdated + { + // no-op, nothing is registered + add { } + remove { } + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnItemRemoved(K key, V value, ItemRemovedReason reason) + { + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnItemUpdated(K key, V oldValue, V value) + { + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetEventSource(object source) + { + } + } +} diff --git a/BitFaster.Caching/Lfu/NodePolicy.cs b/BitFaster.Caching/Lfu/NodePolicy.cs index 89b1861c..9c875879 100644 --- a/BitFaster.Caching/Lfu/NodePolicy.cs +++ b/BitFaster.Caching/Lfu/NodePolicy.cs @@ -4,9 +4,10 @@ namespace BitFaster.Caching.Lfu { - internal interface INodePolicy + internal interface INodePolicy where K : notnull where N : LfuNode + where E : struct, IEventPolicy { N Create(K key, V value); bool IsExpired(N node); @@ -15,11 +16,12 @@ internal interface INodePolicy void AfterRead(N node); void AfterWrite(N node); void OnEvict(N node); - void ExpireEntries

(ref ConcurrentLfuCore cache) where P : struct, INodePolicy; + void ExpireEntries

(ref ConcurrentLfuCore cache) where P : struct, INodePolicy; } - internal struct AccessOrderPolicy : INodePolicy> + internal struct AccessOrderPolicy : INodePolicy, E> where K : notnull + where E : struct, IEventPolicy { [MethodImpl(MethodImplOptions.AggressiveInlining)] public AccessOrderNode Create(K key, V value) @@ -59,13 +61,14 @@ public void OnEvict(AccessOrderNode node) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void ExpireEntries

(ref ConcurrentLfuCore, P> cache) where P : struct, INodePolicy> + public void ExpireEntries

(ref ConcurrentLfuCore, P, E> cache) where P : struct, INodePolicy, E> { } } - internal struct ExpireAfterPolicy : INodePolicy> + internal struct ExpireAfterPolicy : INodePolicy, E> where K : notnull + where E : struct, IEventPolicy { private readonly IExpiryCalculator expiryCalculator; private readonly TimerWheel wheel; @@ -137,7 +140,7 @@ public void OnEvict(TimeOrderNode node) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void ExpireEntries

(ref ConcurrentLfuCore, P> cache) where P : struct, INodePolicy> + public void ExpireEntries

(ref ConcurrentLfuCore, P, E> cache) where P : struct, INodePolicy, E> { wheel.Advance(ref cache, Duration.SinceEpoch()); } diff --git a/BitFaster.Caching/Lfu/TimerWheel.cs b/BitFaster.Caching/Lfu/TimerWheel.cs index cc4675a5..0c1a3f8a 100644 --- a/BitFaster.Caching/Lfu/TimerWheel.cs +++ b/BitFaster.Caching/Lfu/TimerWheel.cs @@ -61,9 +61,10 @@ public TimerWheel() ///

/// /// - public void Advance(ref ConcurrentLfuCore cache, Duration currentTime) + public void Advance(ref ConcurrentLfuCore cache, Duration currentTime) where N : LfuNode - where P : struct, INodePolicy + where P : struct, INodePolicy + where E : struct, IEventPolicy { long previousTime = time; time = currentTime.raw; @@ -101,9 +102,10 @@ public void Advance(ref ConcurrentLfuCore cache, Duration curr } // Expires entries or reschedules into the proper bucket if still active. - private void Expire(ref ConcurrentLfuCore cache, int index, long previousTicks, long delta) + private void Expire(ref ConcurrentLfuCore cache, int index, long previousTicks, long delta) where N : LfuNode - where P : struct, INodePolicy + where P : struct, INodePolicy + where E : struct, IEventPolicy { TimeOrderNode[] timerWheel = wheels[index]; int mask = timerWheel.Length - 1; From 84dee9a348ea1426c20520651fcf1e9c6103f80a Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 9 Jan 2026 10:31:07 -0800 Subject: [PATCH 02/12] handle evicted vs removed --- BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 18 +++++++++++++++++- BitFaster.Caching/Lfu/NodePolicy.cs | 2 +- BitFaster.Caching/Lfu/TimerWheel.cs | 15 ++++++++------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index d5ab530c..0153ab2f 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -646,6 +646,22 @@ private void OnAccess(N node) policy.AfterRead(node); } + private static class RemoveEventInliner + { + private static readonly bool IsEnabled = typeof(E) == typeof(EventPolicy); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void OnRemovedEvent(ConcurrentLfuCore cache, N node) + { + if (IsEnabled) + { + // WasRemoved flag is set via TryRemove, else item is evicted via policy + ItemRemovedReason reason = node.WasRemoved ? ItemRemovedReason.Removed : ItemRemovedReason.Evicted; + cache.eventPolicy.OnItemRemoved(node.Key, node.Value, reason); + } + } + } + private void OnWrite(N node) { // Nodes can be removed while they are in the write buffer, in which case they should @@ -658,7 +674,7 @@ private void OnWrite(N node) { // if a write is in the buffer and is then removed in the buffer, it will enter OnWrite twice. // we mark as deleted to avoid double counting/disposing it - this.eventPolicy.OnItemRemoved(node.Key, node.Value, ItemRemovedReason.Removed); + RemoveEventInliner.OnRemovedEvent(this, node); this.metrics.evictedCount++; Disposer.Dispose(node.Value); node.WasDeleted = true; diff --git a/BitFaster.Caching/Lfu/NodePolicy.cs b/BitFaster.Caching/Lfu/NodePolicy.cs index 9c875879..180e9747 100644 --- a/BitFaster.Caching/Lfu/NodePolicy.cs +++ b/BitFaster.Caching/Lfu/NodePolicy.cs @@ -142,7 +142,7 @@ public void OnEvict(TimeOrderNode node) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void ExpireEntries

(ref ConcurrentLfuCore, P, E> cache) where P : struct, INodePolicy, E> { - wheel.Advance(ref cache, Duration.SinceEpoch()); + wheel.Advance, P, TimeOrderNode, E>(ref cache, Duration.SinceEpoch()); } } } diff --git a/BitFaster.Caching/Lfu/TimerWheel.cs b/BitFaster.Caching/Lfu/TimerWheel.cs index 0c1a3f8a..196db033 100644 --- a/BitFaster.Caching/Lfu/TimerWheel.cs +++ b/BitFaster.Caching/Lfu/TimerWheel.cs @@ -1,4 +1,5 @@ using System; +using BitFaster.Caching.Lru; namespace BitFaster.Caching.Lfu { @@ -51,7 +52,7 @@ public TimerWheel() for (int j = 0; j < wheels[i].Length; j++) { - wheels[i][j] = TimeOrderNode.CreateSentinel(); + wheels[i][j] = TimeOrderNode< K, V>.CreateSentinel(); } } } @@ -61,7 +62,7 @@ public TimerWheel() ///

/// /// - public void Advance(ref ConcurrentLfuCore cache, Duration currentTime) + public void Advance(ref ConcurrentLfuCore cache, Duration currentTime) where N : LfuNode where P : struct, INodePolicy where E : struct, IEventPolicy @@ -85,13 +86,13 @@ public void Advance(ref ConcurrentLfuCore cache, Duratio long previousTicks = (long)(((ulong)previousTime) >> TimerWheel.Shift[i]); long currentTicks = (long)(((ulong)currentTime.raw) >> TimerWheel.Shift[i]); long delta = (currentTicks - previousTicks); - + if (delta <= 0L) { break; } - - Expire(ref cache, i, previousTicks, delta); + + Expire(ref cache, i, previousTicks, delta); } } catch (Exception) @@ -102,7 +103,7 @@ public void Advance(ref ConcurrentLfuCore cache, Duratio } // Expires entries or reschedules into the proper bucket if still active. - private void Expire(ref ConcurrentLfuCore cache, int index, long previousTicks, long delta) + private void Expire(ref ConcurrentLfuCore cache, int index, long previousTicks, long delta) where N : LfuNode where P : struct, INodePolicy where E : struct, IEventPolicy @@ -283,7 +284,7 @@ private long PeekAhead(int index) TimeOrderNode sentinel = timerWheel[probe]; TimeOrderNode next = sentinel.GetNextInTimeOrder(); - return (next == sentinel) ? long.MaxValue : (TimerWheel.Spans[index] - (time & spanMask)); + return (next == sentinel) ? long.MaxValue: (TimerWheel.Spans[index] - (time & spanMask)); } } } From a8b3804a46fa500e6a0252b75a2bfcb372fd0fec Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 9 Jan 2026 10:44:30 -0800 Subject: [PATCH 03/12] fix test build --- .../Lfu/ConcurrentLfuSoakTests.cs | 21 +-- .../Lfu/TimerWheelTests.cs | 120 +++++++++--------- 2 files changed, 71 insertions(+), 70 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs index f2165c2e..08d2bff3 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs @@ -387,15 +387,16 @@ private async Task RunIntegrityCheckAsync(ConcurrentLfu lfu, int it private static void RunIntegrityCheck(ConcurrentLfu cache, ITestOutputHelper output) { - new ConcurrentLfuIntegrityChecker, AccessOrderPolicy>(cache.Core).Validate(output); + new ConcurrentLfuIntegrityChecker, AccessOrderPolicy>, EventPolicy>(cache.Core).Validate(output); } } - internal class ConcurrentLfuIntegrityChecker + internal class ConcurrentLfuIntegrityChecker where N : LfuNode - where P : struct, INodePolicy + where P : struct, INodePolicy + where E : struct, IEventPolicy { - private readonly ConcurrentLfuCore cache; + private readonly ConcurrentLfuCore cache; private readonly LfuNodeList windowLru; private readonly LfuNodeList probationLru; @@ -404,14 +405,14 @@ internal class ConcurrentLfuIntegrityChecker private readonly StripedMpscBuffer readBuffer; private readonly MpscBoundedBuffer writeBuffer; - private static FieldInfo windowLruField = typeof(ConcurrentLfuCore).GetField("windowLru", BindingFlags.NonPublic | BindingFlags.Instance); - private static FieldInfo probationLruField = typeof(ConcurrentLfuCore).GetField("probationLru", BindingFlags.NonPublic | BindingFlags.Instance); - private static FieldInfo protectedLruField = typeof(ConcurrentLfuCore).GetField("protectedLru", BindingFlags.NonPublic | BindingFlags.Instance); + private static FieldInfo windowLruField = typeof(ConcurrentLfuCore).GetField("windowLru", BindingFlags.NonPublic | BindingFlags.Instance); + private static FieldInfo probationLruField = typeof(ConcurrentLfuCore).GetField("probationLru", BindingFlags.NonPublic | BindingFlags.Instance); + private static FieldInfo protectedLruField = typeof(ConcurrentLfuCore).GetField("protectedLru", BindingFlags.NonPublic | BindingFlags.Instance); - private static FieldInfo readBufferField = typeof(ConcurrentLfuCore).GetField("readBuffer", BindingFlags.NonPublic | BindingFlags.Instance); - private static FieldInfo writeBufferField = typeof(ConcurrentLfuCore).GetField("writeBuffer", BindingFlags.NonPublic | BindingFlags.Instance); + private static FieldInfo readBufferField = typeof(ConcurrentLfuCore).GetField("readBuffer", BindingFlags.NonPublic | BindingFlags.Instance); + private static FieldInfo writeBufferField = typeof(ConcurrentLfuCore).GetField("writeBuffer", BindingFlags.NonPublic | BindingFlags.Instance); - public ConcurrentLfuIntegrityChecker(ConcurrentLfuCore cache) + public ConcurrentLfuIntegrityChecker(ConcurrentLfuCore cache) { this.cache = cache; diff --git a/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs b/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs index 3300deac..1e129dcb 100644 --- a/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs @@ -20,8 +20,8 @@ public class TimerWheelTests private readonly TimerWheel timerWheel; private readonly WheelEnumerator wheelEnumerator; private readonly LfuNodeList lfuNodeList; - private readonly ExpireAfterPolicy policy; - private ConcurrentLfuCore, ExpireAfterPolicy> cache; + private readonly ExpireAfterPolicy> policy; + private ConcurrentLfuCore, ExpireAfterPolicy>, NoEventPolicy> cache; public TimerWheelTests(ITestOutputHelper testOutputHelper) { @@ -29,34 +29,34 @@ public TimerWheelTests(ITestOutputHelper testOutputHelper) lfuNodeList = new(); timerWheel = new(); wheelEnumerator = new(timerWheel, testOutputHelper); - policy = new ExpireAfterPolicy(new TestExpiryCalculator()); + policy = new ExpireAfterPolicy>(new TestExpiryCalculator()); cache = new( - Defaults.ConcurrencyLevel, 3, new ThreadPoolScheduler(), EqualityComparer.Default, () => { }, policy); - } - + Defaults.ConcurrencyLevel, 3, new ThreadPoolScheduler(), EqualityComparer.Default, () => { }, policy, default); + } + [Theory] - [MemberData(nameof(ScheduleData))] - public void WhenAdvanceExpiredNodesExpire(long clock, Duration duration, int expiredCount) - { - var items = new List>(); - timerWheel.time = clock; - - foreach (int timeout in new int[] { 25, 90, 240 }) - { - var node = AddNode(1, new DisposeTracker(), new Duration(clock) + Duration.FromSeconds(timeout)); - items.Add(node); + [MemberData(nameof(ScheduleData))] + public void WhenAdvanceExpiredNodesExpire(long clock, Duration duration, int expiredCount) + { + var items = new List>(); + timerWheel.time = clock; + + foreach (int timeout in new int[] { 25, 90, 240 }) + { + var node = AddNode(1, new DisposeTracker(), new Duration(clock) + Duration.FromSeconds(timeout)); + items.Add(node); timerWheel.Schedule(node); - } - - timerWheel.Advance(ref cache, new Duration(clock) + duration); - - var expired = items.Where(n => ((DisposeTracker)n.Value).Expired); - expired.Count().Should().Be(expiredCount); - - foreach (var node in expired) - { - node.GetTimestamp().Should().BeLessThanOrEqualTo(clock + duration.raw); - } + } + + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, new Duration(clock) + duration); + + var expired = items.Where(n => ((DisposeTracker)n.Value).Expired); + expired.Count().Should().Be(expiredCount); + + foreach (var node in expired) + { + node.GetTimestamp().Should().BeLessThanOrEqualTo(clock + duration.raw); + } } [Theory] @@ -68,7 +68,7 @@ public void WhenAdvancedPastItemExpiryItemIsEvicted(long clock2) var item = new DisposeTracker(); timerWheel.Schedule(AddNode(1, item, new Duration(clock2 + TimerWheel.Spans[0]))); - timerWheel.Advance(ref cache, new Duration(clock2 + 13 * TimerWheel.Spans[0])); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, new Duration(clock2 + 13 * TimerWheel.Spans[0])); item.Expired.Should().BeTrue(); } @@ -90,13 +90,13 @@ public void WhenAdvanceDifferentWheelsNodeIsRescheduled(long clock) var initialPosition = wheelEnumerator.PositionOf(120); Duration t45 = clockD + Duration.FromSeconds(45); // discard T15, T120 in wheel[1] - timerWheel.Advance(ref cache, t45); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, t45); lfuNodeList.Count.Should().Be(1); // verify discarded T15 wheelEnumerator.PositionOf(15).Should().Be(WheelPosition.None); Duration t110 = clockD + Duration.FromSeconds(110); - timerWheel.Advance(ref cache, t110); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, t110); lfuNodeList.Count.Should().Be(1); // verify not discarded, T120 in wheel[0] var rescheduledPosition = wheelEnumerator.PositionOf(120); @@ -104,12 +104,12 @@ public void WhenAdvanceDifferentWheelsNodeIsRescheduled(long clock) rescheduledPosition.Should().BeLessThan(initialPosition); Duration t130 = clockD + Duration.FromSeconds(130); - timerWheel.Advance(ref cache, t130); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, t130); lfuNodeList.Count.Should().Be(0); // verify discarded T120 wheelEnumerator.PositionOf(120).Should().Be(WheelPosition.None); - } - + } + [Fact] public void WhenAdvanceOverflowsAndItemIsExpiredItemIsEvicted() { @@ -117,7 +117,7 @@ public void WhenAdvanceOverflowsAndItemIsExpiredItemIsEvicted() var item = new DisposeTracker(); timerWheel.Schedule(AddNode(1, item, new Duration(timerWheel.time + TimerWheel.Spans[0]))); - timerWheel.Advance(ref cache, new Duration(timerWheel.time + (TimerWheel.Spans[3] * 365))); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, new Duration(timerWheel.time + (TimerWheel.Spans[3] * 365))); this.lfuNodeList.Count.Should().Be(0); } @@ -139,7 +139,7 @@ public void WhenAdvanceBackwardsNothingIsEvicted(long clock) for (int i = 0; i < TimerWheel.Buckets.Length; i++) { - timerWheel.Advance(ref cache, new Duration(clock - 3 * TimerWheel.Spans[i])); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, new Duration(clock - 3 * TimerWheel.Spans[i])); } this.lfuNodeList.Count.Should().Be(1_000); @@ -155,7 +155,7 @@ public void WhenAdvanceThrowsCurrentTimeIsNotAdvanced() timerWheel.Schedule(AddNode(1, new DisposeThrows(), new Duration(clock.raw + TimerWheel.Spans[1]))); // This should expire the node, call evict, then throw via DisposeThrows.Dispose() - Action advance = () => timerWheel.Advance(ref cache, new Duration(clock.raw + (2 * TimerWheel.Spans[1]))); + Action advance = () => timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, new Duration(clock.raw + (2 * TimerWheel.Spans[1]))); advance.Should().Throw(); timerWheel.time.Should().Be(clock.raw); @@ -167,21 +167,21 @@ public void WhenEmptyGetExpirationDelayIsMax(long clock) { timerWheel.time = clock; timerWheel.GetExpirationDelay().raw.Should().Be(long.MaxValue); - } - + } + [Theory] [MemberData(nameof(ClockData))] public void WhenScheduledMaxNodeIsInOuterWheel(long clock) - { + { var clockD = new Duration(clock); - timerWheel.time = clock; - - Duration tMax = clockD + new Duration(long.MaxValue); - - timerWheel.Schedule(AddNode(1, new DisposeTracker(), tMax)); - - var initialPosition = wheelEnumerator.PositionOf(1); - initialPosition.wheel.Should().Be(4); + timerWheel.time = clock; + + Duration tMax = clockD + new Duration(long.MaxValue); + + timerWheel.Schedule(AddNode(1, new DisposeTracker(), tMax)); + + var initialPosition = wheelEnumerator.PositionOf(1); + initialPosition.wheel.Should().Be(4); } [Theory] @@ -224,7 +224,7 @@ public void WhenScheduledInDifferentWheelsDelayIsCorrect(long clock) timerWheel.Schedule(AddNode(2, new DisposeTracker(), t80)); // wheel 1 Duration t45 = clockD + Duration.FromSeconds(45); // discard T15, T80 in wheel[1] - timerWheel.Advance(ref cache, t45); + timerWheel.Advance, ExpireAfterPolicy>, NoEventPolicy, NoEventPolicy>(ref cache, t45); lfuNodeList.Count.Should().Be(1); // verify discarded @@ -237,9 +237,9 @@ public void WhenScheduledInDifferentWheelsDelayIsCorrect(long clock) } [Fact] - public void WhenScheduledThenDescheduledNodeIsRemoved() - { - var node = AddNode(1, new DisposeTracker(), Duration.SinceEpoch()); + public void WhenScheduledThenDescheduledNodeIsRemoved() + { + var node = AddNode(1, new DisposeTracker(), Duration.SinceEpoch()); timerWheel.Schedule(node); wheelEnumerator.PositionOf(1).Should().NotBe(WheelPosition.None); @@ -248,13 +248,13 @@ public void WhenScheduledThenDescheduledNodeIsRemoved() wheelEnumerator.PositionOf(1).Should().Be(WheelPosition.None); node.GetNextInTimeOrder().Should().BeNull(); node.GetPreviousInTimeOrder().Should().BeNull(); - } - + } + [Fact] - public void WhenRescheduledLaterNodeIsMoved() - { - var time = Duration.SinceEpoch(); - var node = AddNode(1, new DisposeTracker(), time); + public void WhenRescheduledLaterNodeIsMoved() + { + var time = Duration.SinceEpoch(); + var node = AddNode(1, new DisposeTracker(), time); timerWheel.Schedule(node); var initial = wheelEnumerator.PositionOf(1); @@ -265,9 +265,9 @@ public void WhenRescheduledLaterNodeIsMoved() } [Fact] - public void WhenDetachedRescheduleIsNoOp() - { - var time = Duration.SinceEpoch(); + public void WhenDetachedRescheduleIsNoOp() + { + var time = Duration.SinceEpoch(); var node = AddNode(1, new DisposeTracker(), time); timerWheel.Reschedule(node); From 582d27c7137230ed3f0dde3f61714ea75e207bdb Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 9 Jan 2026 10:49:23 -0800 Subject: [PATCH 04/12] fix tests --- BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs | 4 ++-- BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs index a301a1bb..8632b659 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs @@ -514,9 +514,9 @@ public void ExpireAfterWriteIsDisabled() } [Fact] - public void EventsAreDisabled() + public void EventsAreEnabled() { - cache.Events.HasValue.Should().BeFalse(); + cache.Events.HasValue.Should().BeTrue(); } [Fact] diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs index 328e5e02..c4f67bd7 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs @@ -75,10 +75,10 @@ public void MetricsHasValueIsTrue() } [Fact] - public void EventsHasValueIsFalse() + public void EventsAreEnabled() { var x = new ConcurrentTLfu(3, new TestExpiryCalculator()); - x.Events.HasValue.Should().BeFalse(); + x.Events.HasValue.Should().BeTrue(); } [Fact] From e3ce5749ce4a5ae042f730b763a9838e716605ed Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 9 Jan 2026 10:58:04 -0800 Subject: [PATCH 05/12] format --- BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs | 2 +- BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 2 +- BitFaster.Caching/Lfu/TimerWheel.cs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs b/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs index 1e129dcb..4e0547b0 100644 --- a/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs @@ -20,7 +20,7 @@ public class TimerWheelTests private readonly TimerWheel timerWheel; private readonly WheelEnumerator wheelEnumerator; private readonly LfuNodeList lfuNodeList; - private readonly ExpireAfterPolicy> policy; + private readonly ExpireAfterPolicy> policy; private ConcurrentLfuCore, ExpireAfterPolicy>, NoEventPolicy> cache; public TimerWheelTests(ITestOutputHelper testOutputHelper) diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 0153ab2f..9f1f6cc7 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -648,7 +648,7 @@ private void OnAccess(N node) private static class RemoveEventInliner { - private static readonly bool IsEnabled = typeof(E) == typeof(EventPolicy); + private static readonly bool IsEnabled = typeof(E) == typeof(EventPolicy); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void OnRemovedEvent(ConcurrentLfuCore cache, N node) diff --git a/BitFaster.Caching/Lfu/TimerWheel.cs b/BitFaster.Caching/Lfu/TimerWheel.cs index 196db033..2ddfb061 100644 --- a/BitFaster.Caching/Lfu/TimerWheel.cs +++ b/BitFaster.Caching/Lfu/TimerWheel.cs @@ -52,7 +52,7 @@ public TimerWheel() for (int j = 0; j < wheels[i].Length; j++) { - wheels[i][j] = TimeOrderNode< K, V>.CreateSentinel(); + wheels[i][j] = TimeOrderNode.CreateSentinel(); } } } @@ -86,12 +86,12 @@ public void Advance(ref ConcurrentLfuCore cache, Dura long previousTicks = (long)(((ulong)previousTime) >> TimerWheel.Shift[i]); long currentTicks = (long)(((ulong)currentTime.raw) >> TimerWheel.Shift[i]); long delta = (currentTicks - previousTicks); - + if (delta <= 0L) { break; } - + Expire(ref cache, i, previousTicks, delta); } } @@ -284,7 +284,7 @@ private long PeekAhead(int index) TimeOrderNode sentinel = timerWheel[probe]; TimeOrderNode next = sentinel.GetNextInTimeOrder(); - return (next == sentinel) ? long.MaxValue: (TimerWheel.Spans[index] - (time & spanMask)); + return (next == sentinel) ? long.MaxValue : (TimerWheel.Spans[index] - (time & spanMask)); } } } From dccab4167fb3f208178a4bc7b3c7395ed8a9800e Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 9 Jan 2026 13:45:57 -0800 Subject: [PATCH 06/12] tests + fix props --- .../Lfu/ConcurrentLfuTests.cs | 164 +++++++++++++++++- BitFaster.Caching/Lfu/ConcurrentLfu.cs | 54 +++++- BitFaster.Caching/Lfu/ConcurrentTLfu.cs | 54 +++++- BitFaster.Caching/Lfu/EventPolicy.cs | 17 -- 4 files changed, 269 insertions(+), 20 deletions(-) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs index 8632b659..0fcc9e77 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs @@ -21,6 +21,19 @@ public class ConcurrentLfuTests private ConcurrentLfu cache = new ConcurrentLfu(1, 20, new BackgroundThreadScheduler(), EqualityComparer.Default); private ValueFactory valueFactory = new ValueFactory(); + private List> removedItems = new List>(); + private List> updatedItems = new List>(); + + private void OnLfuItemRemoved(object sender, ItemRemovedEventArgs e) + { + removedItems.Add(e); + } + + private void OnLfuItemUpdated(object sender, ItemUpdatedEventArgs e) + { + updatedItems.Add(e); + } + public ConcurrentLfuTests(ITestOutputHelper output) { this.output = output; @@ -804,9 +817,158 @@ private void LogLru() { #if DEBUG this.output.WriteLine(cache.FormatLfuString()); -#endif +#endif + } + + [Fact] + public void WhenItemIsRemovedRemovedEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + lfuEvents.GetOrAdd(1, i => i + 2); + + lfuEvents.TryRemove(1).Should().BeTrue(); + + // Maintenance is needed for events to be processed + lfuEvents.DoMaintenance(); + + removedItems.Count.Should().Be(1); + removedItems[0].Key.Should().Be(1); + removedItems[0].Value.Should().Be(3); + removedItems[0].Reason.Should().Be(ItemRemovedReason.Removed); + } + + [Fact] + public void WhenItemRemovedEventIsUnregisteredEventIsNotFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + lfuEvents.Events.Value.ItemRemoved -= OnLfuItemRemoved; + + lfuEvents.GetOrAdd(1, i => i + 1); + lfuEvents.TryRemove(1); + lfuEvents.DoMaintenance(); + + removedItems.Count.Should().Be(0); + } + + [Fact] + public void WhenValueEvictedItemRemovedEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentLfu(6); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + // Fill cache to capacity + for (int i = 0; i < 6; i++) + { + lfuEvents.GetOrAdd(i, i => i); + } + + // This should trigger eviction + lfuEvents.GetOrAdd(100, i => i); + lfuEvents.DoMaintenance(); + + // At least one item should be evicted + removedItems.Count.Should().BeGreaterThan(0); + removedItems.Any(r => r.Reason == ItemRemovedReason.Evicted).Should().BeTrue(); + } + + [Fact] + public void WhenItemsAreTrimmedAnEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + for (int i = 0; i < 6; i++) + { + lfuEvents.GetOrAdd(i, i => i); + } + + lfuEvents.Trim(2); + + removedItems.Count.Should().Be(2); + removedItems.All(r => r.Reason == ItemRemovedReason.Trimmed).Should().BeTrue(); } + [Fact] + public void WhenItemsAreClearedAnEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + for (int i = 0; i < 6; i++) + { + lfuEvents.GetOrAdd(i, i => i); + } + + lfuEvents.Clear(); + + removedItems.Count.Should().Be(6); + removedItems.All(r => r.Reason == ItemRemovedReason.Cleared).Should().BeTrue(); + } + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + [Fact] + public void WhenItemExistsAddOrUpdateFiresUpdateEvent() + { + updatedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + lfuEvents.Events.Value.ItemUpdated += OnLfuItemUpdated; + + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(2, 3); + + lfuEvents.AddOrUpdate(1, 3); + + this.updatedItems.Count.Should().Be(1); + this.updatedItems[0].Key.Should().Be(1); + this.updatedItems[0].OldValue.Should().Be(2); + this.updatedItems[0].NewValue.Should().Be(3); + } + + [Fact] + public void WhenItemExistsTryUpdateFiresUpdateEvent() + { + updatedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + lfuEvents.Events.Value.ItemUpdated += OnLfuItemUpdated; + + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(2, 3); + + lfuEvents.TryUpdate(1, 3); + + this.updatedItems.Count.Should().Be(1); + this.updatedItems[0].Key.Should().Be(1); + this.updatedItems[0].OldValue.Should().Be(2); + this.updatedItems[0].NewValue.Should().Be(3); + } + + [Fact] + public void WhenItemUpdatedEventIsUnregisteredEventIsNotFired() + { + updatedItems.Clear(); + var lfuEvents = new ConcurrentLfu(20); + + lfuEvents.Events.Value.ItemUpdated += OnLfuItemUpdated; + lfuEvents.Events.Value.ItemUpdated -= OnLfuItemUpdated; + + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(1, 2); + + updatedItems.Count.Should().Be(0); + } +#endif + public class ValueFactory { public int timesCalled; diff --git a/BitFaster.Caching/Lfu/ConcurrentLfu.cs b/BitFaster.Caching/Lfu/ConcurrentLfu.cs index 922ad674..45913871 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfu.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfu.cs @@ -83,7 +83,9 @@ private void DrainBuffers() public Optional Metrics => core.Metrics; /// - public Optional> Events => new(core.eventPolicy); + public Optional> Events => new(new Proxy(this)); + + internal ref EventPolicy EventPolicyRef => ref this.core.eventPolicy; /// public CachePolicy Policy => core.Policy; @@ -120,6 +122,7 @@ public void AddOrUpdate(K key, V value) public void Clear() { core.Clear(); + DoMaintenance(); } /// @@ -150,6 +153,7 @@ public ValueTask GetOrAddAsync(K key, Func> valueFacto public void Trim(int itemCount) { core.Trim(itemCount); + DoMaintenance(); } /// @@ -214,6 +218,54 @@ public string FormatLfuString() } #endif + // To get JIT optimizations, policies must be structs. + // If the structs are returned directly via properties, they will be copied. Since + // eventPolicy is a mutable struct, copy is bad. One workaround is to store the + // state within the struct in an object. Since the struct points to the same object + // it becomes immutable. However, this object is then somewhere else on the + // heap, which slows down the policies with hit counter logic in benchmarks. Likely + // this approach keeps the structs data members in the same CPU cache line as the LFU. + private class Proxy : ICacheEvents + { + private readonly ConcurrentLfu lfu; + + public Proxy(ConcurrentLfu lfu) + { + this.lfu = lfu; + } + + public event EventHandler> ItemRemoved + { + add + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemRemoved += value; + } + remove + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemRemoved -= value; + } + } + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + public event EventHandler> ItemUpdated + { + add + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemUpdated += value; + } + remove + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemUpdated -= value; + } + } +#endif + } + [ExcludeFromCodeCoverage] internal class LfuDebugView where N : LfuNode diff --git a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs index db574560..530b35df 100644 --- a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs +++ b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs @@ -42,7 +42,9 @@ private void DrainBuffers() public Optional Metrics => core.Metrics; /// - public Optional> Events => new(core.eventPolicy); + public Optional> Events => new(new Proxy(this)); + + internal ref EventPolicy EventPolicyRef => ref this.core.eventPolicy; /// public CachePolicy Policy => CreatePolicy(); @@ -71,6 +73,7 @@ public void AddOrUpdate(K key, V value) public void Clear() { core.Clear(); + DoMaintenance(); } /// @@ -101,6 +104,7 @@ public ValueTask GetOrAddAsync(K key, Func> valueFacto public void Trim(int itemCount) { core.Trim(itemCount); + DoMaintenance(); } /// @@ -194,5 +198,53 @@ public void TrimExpired() { DoMaintenance(); } + + // To get JIT optimizations, policies must be structs. + // If the structs are returned directly via properties, they will be copied. Since + // eventPolicy is a mutable struct, copy is bad. One workaround is to store the + // state within the struct in an object. Since the struct points to the same object + // it becomes immutable. However, this object is then somewhere else on the + // heap, which slows down the policies with hit counter logic in benchmarks. Likely + // this approach keeps the structs data members in the same CPU cache line as the LFU. + private class Proxy : ICacheEvents + { + private readonly ConcurrentTLfu lfu; + + public Proxy(ConcurrentTLfu lfu) + { + this.lfu = lfu; + } + + public event EventHandler> ItemRemoved + { + add + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemRemoved += value; + } + remove + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemRemoved -= value; + } + } + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + public event EventHandler> ItemUpdated + { + add + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemUpdated += value; + } + remove + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemUpdated -= value; + } + } +#endif + } } } diff --git a/BitFaster.Caching/Lfu/EventPolicy.cs b/BitFaster.Caching/Lfu/EventPolicy.cs index 922d3ad4..d56f6946 100644 --- a/BitFaster.Caching/Lfu/EventPolicy.cs +++ b/BitFaster.Caching/Lfu/EventPolicy.cs @@ -13,8 +13,6 @@ namespace BitFaster.Caching.Lfu public struct EventPolicy : IEventPolicy where K : notnull { - private Counter evictedCount; - private Counter updatedCount; private object eventSource; /// @@ -23,20 +21,9 @@ public struct EventPolicy : IEventPolicy /// public event EventHandler> ItemUpdated; - /// - public long Evicted => this.evictedCount.Count(); - - /// - public long Updated => this.updatedCount.Count(); - /// public void OnItemRemoved(K key, V value, ItemRemovedReason reason) { - if (reason == ItemRemovedReason.Evicted) - { - this.evictedCount.Increment(); - } - // passing 'this' as source boxes the struct, and is anyway the wrong object this.ItemRemoved?.Invoke(this.eventSource, new ItemRemovedEventArgs(key, value, reason)); } @@ -44,8 +31,6 @@ public void OnItemRemoved(K key, V value, ItemRemovedReason reason) /// public void OnItemUpdated(K key, V oldValue, V newValue) { - this.updatedCount.Increment(); - // passing 'this' as source boxes the struct, and is anyway the wrong object this.ItemUpdated?.Invoke(this.eventSource, new ItemUpdatedEventArgs(key, oldValue, newValue)); } @@ -53,8 +38,6 @@ public void OnItemUpdated(K key, V oldValue, V newValue) /// public void SetEventSource(object source) { - this.evictedCount = new Counter(); - this.updatedCount = new Counter(); this.eventSource = source; } } From f74df27d674381e627960d3404fbed0b5648c57d Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 12 Jan 2026 11:21:33 -0800 Subject: [PATCH 07/12] fix trim and clear --- BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 44 ++++++++++++---------- BitFaster.Caching/Lfu/TimerWheel.cs | 2 +- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 9f1f6cc7..e812db22 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -155,7 +155,7 @@ public void AddOrUpdate(K key, V value) public void Clear() { - Trim(int.MaxValue); + Trim(int.MaxValue, ItemRemovedReason.Cleared); lock (maintenanceLock) { @@ -166,15 +166,20 @@ public void Clear() } public void Trim(int itemCount) + { + Trim(itemCount, ItemRemovedReason.Trimmed); + } + + private void Trim(int itemCount, ItemRemovedReason reason) { List> candidates; lock (maintenanceLock) { - Maintenance(); + Maintenance(reason: reason); int lruCount = this.windowLru.Count + this.probationLru.Count + this.protectedLru.Count; itemCount = Math.Min(itemCount, lruCount); - candidates = new(itemCount); + candidates = new List>(itemCount); // Note: this is LRU order eviction, Caffeine is based on frequency // walk in lru order, get itemCount keys to evict @@ -182,15 +187,14 @@ public void Trim(int 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) #endif { - this.TryRemove(candidate.Key); - } + Evict(candidate, reason); + } } private bool TryAdd(K key, V value) @@ -565,7 +569,7 @@ internal void DrainBuffers() } } - private bool Maintenance(N? droppedWrite = null) + private bool Maintenance(N? droppedWrite = null, ItemRemovedReason reason = ItemRemovedReason.Evicted) { this.drainStatus.VolatileWrite(DrainStatus.ProcessingToIdle); @@ -602,7 +606,7 @@ private bool Maintenance(N? droppedWrite = null) } policy.ExpireEntries(ref this); - EvictEntries(); + EvictEntries(reason); this.capacity.OptimizePartitioning(this.metrics, this.cmSketch.ResetSampleSize); ReFitProtected(); @@ -729,10 +733,10 @@ private void PromoteProbation(LfuNode node) } } - private void EvictEntries() + private void EvictEntries(ItemRemovedReason reason) { var candidate = EvictFromWindow(); - EvictFromMain(candidate); + EvictFromMain(candidate, reason); } private LfuNode EvictFromWindow() @@ -779,7 +783,7 @@ public void Next() } } - private void EvictFromMain(LfuNode candidateNode) + private void EvictFromMain(LfuNode candidateNode, ItemRemovedReason reason) { var victim = new EvictIterator(this.cmSketch, this.probationLru.First); // victims are LRU position in probation var candidate = new EvictIterator(this.cmSketch, candidateNode); @@ -795,7 +799,7 @@ private void EvictFromMain(LfuNode candidateNode) if (victim.node == candidate.node) { - Evict(candidate.node!); + Evict(candidate.node!, reason); break; } @@ -803,7 +807,7 @@ private void EvictFromMain(LfuNode candidateNode) { var evictee = candidate.node; candidate.Next(); - Evict(evictee); + Evict(evictee, reason); continue; } @@ -811,7 +815,7 @@ private void EvictFromMain(LfuNode candidateNode) { var evictee = victim.node; victim.Next(); - Evict(evictee); + Evict(evictee, reason); continue; } @@ -824,7 +828,7 @@ private void EvictFromMain(LfuNode candidateNode) victim.Next(); candidate.Next(); - Evict(evictee); + Evict(evictee, reason); } else { @@ -833,7 +837,7 @@ private void EvictFromMain(LfuNode candidateNode) // candidate is initialized to first cand, and iterates forwards candidate.Next(); - Evict(evictee); + Evict(evictee, reason); } } @@ -845,11 +849,11 @@ private void EvictFromMain(LfuNode candidateNode) if (AdmitCandidate(victim1.Key, victim2.Key)) { - Evict(victim2); + Evict(victim2, reason); } else { - Evict(victim1); + Evict(victim1, reason); } } } @@ -863,7 +867,7 @@ private bool AdmitCandidate(K candidateKey, K victimKey) return candidateFreq > victimFreq; } - internal void Evict(LfuNode evictee) + internal void Evict(LfuNode evictee, ItemRemovedReason reason) { evictee.WasRemoved = true; evictee.WasDeleted = true; @@ -877,7 +881,7 @@ internal void Evict(LfuNode evictee) ((ICollection>)this.dictionary).Remove(kvp); #endif evictee.list?.Remove(evictee); - this.eventPolicy.OnItemRemoved(evictee.Key, evictee.Value, ItemRemovedReason.Evicted); + this.eventPolicy.OnItemRemoved(evictee.Key, evictee.Value, reason); Disposer.Dispose(evictee.Value); this.metrics.evictedCount++; diff --git a/BitFaster.Caching/Lfu/TimerWheel.cs b/BitFaster.Caching/Lfu/TimerWheel.cs index 2ddfb061..ee74fb30 100644 --- a/BitFaster.Caching/Lfu/TimerWheel.cs +++ b/BitFaster.Caching/Lfu/TimerWheel.cs @@ -135,7 +135,7 @@ private void Expire(ref ConcurrentLfuCore cache, int { if ((node.GetTimestamp() - time) < 0) { - cache.Evict(node); + cache.Evict(node, ItemRemovedReason.Evicted); } else { From b79f649e672493e72202bbc1b148f534929f1489 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 12 Jan 2026 11:28:19 -0800 Subject: [PATCH 08/12] lfucore --- BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index e812db22..ff118f11 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -194,7 +194,7 @@ private void Trim(int itemCount, ItemRemovedReason reason) #endif { Evict(candidate, reason); - } + } } private bool TryAdd(K key, V value) From 1e8dc28e014ca0cc8de8cd22f30d2a2ad7026d93 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 12 Jan 2026 18:48:02 -0800 Subject: [PATCH 09/12] test event policies --- .../Lfu/EventPolicyTests.cs | 208 ++++++++++++++++++ .../Lfu/NoEventPolicyTests.cs | 85 +++++++ BitFaster.Caching/Lfu/NoEventPolicy.cs | 6 - 3 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 BitFaster.Caching.UnitTests/Lfu/EventPolicyTests.cs create mode 100644 BitFaster.Caching.UnitTests/Lfu/NoEventPolicyTests.cs diff --git a/BitFaster.Caching.UnitTests/Lfu/EventPolicyTests.cs b/BitFaster.Caching.UnitTests/Lfu/EventPolicyTests.cs new file mode 100644 index 00000000..437ba3ea --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lfu/EventPolicyTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using BitFaster.Caching.Lfu; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Lfu +{ + public class EventPolicyTests + { + private EventPolicy eventPolicy = default; + + public EventPolicyTests() + { + eventPolicy.SetEventSource(this); + } + + [Fact] + public void OnItemRemovedInvokesEvent() + { + List> eventList = new(); + + eventPolicy.ItemRemoved += (source, args) => eventList.Add(args); + + eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + eventList.Should().HaveCount(1); + eventList[0].Key.Should().Be(1); + eventList[0].Value.Should().Be(2); + eventList[0].Reason.Should().Be(ItemRemovedReason.Evicted); + } + + [Fact] + public void OnItemUpdatedInvokesEvent() + { + List> eventList = new(); + + eventPolicy.ItemUpdated += (source, args) => eventList.Add(args); + + eventPolicy.OnItemUpdated(1, 2, 3); + + eventList.Should().HaveCount(1); + eventList[0].Key.Should().Be(1); + eventList[0].OldValue.Should().Be(2); + eventList[0].NewValue.Should().Be(3); + } + + [Fact] + public void EventSourceIsSetItemRemovedEventUsesSource() + { + List eventSourceList = new(); + + eventPolicy.SetEventSource(this); + + eventPolicy.ItemRemoved += (source, args) => eventSourceList.Add(source); + + eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + eventSourceList.Should().HaveCount(1); + eventSourceList[0].Should().Be(this); + } + + [Fact] + public void EventSourceIsSetItemUpdatedEventUsesSource() + { + List eventSourceList = new(); + + eventPolicy.SetEventSource(this); + + eventPolicy.ItemUpdated += (source, args) => eventSourceList.Add(source); + + eventPolicy.OnItemUpdated(1, 2, 3); + + eventSourceList.Should().HaveCount(1); + eventSourceList[0].Should().Be(this); + } + + [Fact] + public void MultipleItemRemovedSubscribersAllInvoked() + { + int invocationCount = 0; + + eventPolicy.ItemRemoved += (source, args) => invocationCount++; + eventPolicy.ItemRemoved += (source, args) => invocationCount++; + eventPolicy.ItemRemoved += (source, args) => invocationCount++; + + eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + invocationCount.Should().Be(3); + } + + [Fact] + public void MultipleItemUpdatedSubscribersAllInvoked() + { + int invocationCount = 0; + + eventPolicy.ItemUpdated += (source, args) => invocationCount++; + eventPolicy.ItemUpdated += (source, args) => invocationCount++; + eventPolicy.ItemUpdated += (source, args) => invocationCount++; + + eventPolicy.OnItemUpdated(1, 2, 3); + + invocationCount.Should().Be(3); + } + + [Fact] + public void ItemRemovedEventCanBeUnsubscribed() + { + int invocationCount = 0; + + EventHandler> handler = (source, args) => invocationCount++; + + eventPolicy.ItemRemoved += handler; + eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + invocationCount.Should().Be(1); + + eventPolicy.ItemRemoved -= handler; + eventPolicy.OnItemRemoved(3, 4, ItemRemovedReason.Evicted); + + invocationCount.Should().Be(1); + } + + [Fact] + public void ItemUpdatedEventCanBeUnsubscribed() + { + int invocationCount = 0; + + EventHandler> handler = (source, args) => invocationCount++; + + eventPolicy.ItemUpdated += handler; + eventPolicy.OnItemUpdated(1, 2, 3); + + invocationCount.Should().Be(1); + + eventPolicy.ItemUpdated -= handler; + eventPolicy.OnItemUpdated(4, 5, 6); + + invocationCount.Should().Be(1); + } + + [Fact] + public void OnItemRemovedWithoutSubscribersDoesNotThrow() + { + Action act = () => eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + act.Should().NotThrow(); + } + + [Fact] + public void OnItemUpdatedWithoutSubscribersDoesNotThrow() + { + Action act = () => eventPolicy.OnItemUpdated(1, 2, 3); + + act.Should().NotThrow(); + } + + [Fact] + public void MultipleOnItemRemovedCallsInvokeMultipleEvents() + { + List> eventList = new(); + + eventPolicy.ItemRemoved += (source, args) => eventList.Add(args); + + eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + eventPolicy.OnItemRemoved(3, 4, ItemRemovedReason.Removed); + eventPolicy.OnItemRemoved(5, 6, ItemRemovedReason.Evicted); + + eventList.Should().HaveCount(3); + eventList[0].Key.Should().Be(1); + eventList[1].Key.Should().Be(3); + eventList[2].Key.Should().Be(5); + } + + [Fact] + public void MultipleOnItemUpdatedCallsInvokeMultipleEvents() + { + List> eventList = new(); + + eventPolicy.ItemUpdated += (source, args) => eventList.Add(args); + + eventPolicy.OnItemUpdated(1, 2, 3); + eventPolicy.OnItemUpdated(4, 5, 6); + eventPolicy.OnItemUpdated(7, 8, 9); + + eventList.Should().HaveCount(3); + eventList[0].Key.Should().Be(1); + eventList[1].Key.Should().Be(4); + eventList[2].Key.Should().Be(7); + } + + [Fact] + public void ItemRemovedAndItemUpdatedEventsAreIndependent() + { + List> removedEventList = new(); + List> updatedEventList = new(); + + eventPolicy.ItemRemoved += (source, args) => removedEventList.Add(args); + eventPolicy.ItemUpdated += (source, args) => updatedEventList.Add(args); + + eventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + eventPolicy.OnItemUpdated(3, 4, 5); + + removedEventList.Should().HaveCount(1); + updatedEventList.Should().HaveCount(1); + } + } +} diff --git a/BitFaster.Caching.UnitTests/Lfu/NoEventPolicyTests.cs b/BitFaster.Caching.UnitTests/Lfu/NoEventPolicyTests.cs new file mode 100644 index 00000000..f7029bcd --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lfu/NoEventPolicyTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using BitFaster.Caching.Lfu; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Lfu +{ + public class NoEventPolicyTests + { + private NoEventPolicy noEventPolicy = default; + + [Fact] + public void OnItemRemovedDoesNothing() + { + Action act = () => noEventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + act.Should().NotThrow(); + } + + [Fact] + public void OnItemUpdatedDoesNothing() + { + Action act = () => noEventPolicy.OnItemUpdated(1, 2, 3); + + act.Should().NotThrow(); + } + + [Fact] + public void SetEventSourceDoesNothing() + { + Action act = () => noEventPolicy.SetEventSource(this); + + act.Should().NotThrow(); + } + + [Fact] + public void ItemRemovedEventCanBeSubscribedWithoutEffect() + { + List> eventList = new(); + + noEventPolicy.ItemRemoved += (source, args) => eventList.Add(args); + + noEventPolicy.OnItemRemoved(1, 2, ItemRemovedReason.Evicted); + + eventList.Should().BeEmpty(); + } + + [Fact] + public void ItemUpdatedEventCanBeSubscribedWithoutEffect() + { + List> eventList = new(); + + noEventPolicy.ItemUpdated += (source, args) => eventList.Add(args); + + noEventPolicy.OnItemUpdated(1, 2, 3); + + eventList.Should().BeEmpty(); + } + + [Fact] + public void ItemRemovedEventCanBeUnsubscribedWithoutEffect() + { + EventHandler> handler = (source, args) => { }; + + Action subscribe = () => noEventPolicy.ItemRemoved += handler; + Action unsubscribe = () => noEventPolicy.ItemRemoved -= handler; + + subscribe.Should().NotThrow(); + unsubscribe.Should().NotThrow(); + } + + [Fact] + public void ItemUpdatedEventCanBeUnsubscribedWithoutEffect() + { + EventHandler> handler = (source, args) => { }; + + Action subscribe = () => noEventPolicy.ItemUpdated += handler; + Action unsubscribe = () => noEventPolicy.ItemUpdated -= handler; + + subscribe.Should().NotThrow(); + unsubscribe.Should().NotThrow(); + } + } +} diff --git a/BitFaster.Caching/Lfu/NoEventPolicy.cs b/BitFaster.Caching/Lfu/NoEventPolicy.cs index d9e7c190..c99289d1 100644 --- a/BitFaster.Caching/Lfu/NoEventPolicy.cs +++ b/BitFaster.Caching/Lfu/NoEventPolicy.cs @@ -12,12 +12,6 @@ namespace BitFaster.Caching.Lfu public struct NoEventPolicy : IEventPolicy where K : notnull { - /// - public long Updated => 0; - - /// - public long Evicted => 0; - /// public event EventHandler> ItemRemoved { From 06e7a02d996be92ce5981e3917113278c4c00cec Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 12 Jan 2026 18:50:49 -0800 Subject: [PATCH 10/12] make new types internal --- BitFaster.Caching/Lfu/EventPolicy.cs | 2 +- BitFaster.Caching/Lfu/IEventPolicy.cs | 2 +- BitFaster.Caching/Lfu/NoEventPolicy.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BitFaster.Caching/Lfu/EventPolicy.cs b/BitFaster.Caching/Lfu/EventPolicy.cs index d56f6946..d29608d9 100644 --- a/BitFaster.Caching/Lfu/EventPolicy.cs +++ b/BitFaster.Caching/Lfu/EventPolicy.cs @@ -10,7 +10,7 @@ namespace BitFaster.Caching.Lfu /// The type of the Key /// The type of the value [DebuggerDisplay("Upd = {Updated}, Evict = {Evicted}")] - public struct EventPolicy : IEventPolicy + internal struct EventPolicy : IEventPolicy where K : notnull { private object eventSource; diff --git a/BitFaster.Caching/Lfu/IEventPolicy.cs b/BitFaster.Caching/Lfu/IEventPolicy.cs index 238863d3..a1812e93 100644 --- a/BitFaster.Caching/Lfu/IEventPolicy.cs +++ b/BitFaster.Caching/Lfu/IEventPolicy.cs @@ -6,7 +6,7 @@ namespace BitFaster.Caching.Lfu /// /// The type of the key. /// The type of the value. - public interface IEventPolicy : ICacheEvents + internal interface IEventPolicy : ICacheEvents where K : notnull { /// diff --git a/BitFaster.Caching/Lfu/NoEventPolicy.cs b/BitFaster.Caching/Lfu/NoEventPolicy.cs index c99289d1..414e4a03 100644 --- a/BitFaster.Caching/Lfu/NoEventPolicy.cs +++ b/BitFaster.Caching/Lfu/NoEventPolicy.cs @@ -9,7 +9,7 @@ namespace BitFaster.Caching.Lfu /// /// The type of the key. /// The type of the value. - public struct NoEventPolicy : IEventPolicy + internal struct NoEventPolicy : IEventPolicy where K : notnull { /// From 21586f43c2e0c2a19c877d5f9bb423aa90847953 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Tue, 13 Jan 2026 13:31:48 -0800 Subject: [PATCH 11/12] proxy comment --- BitFaster.Caching/Lfu/ConcurrentLfu.cs | 7 ++----- BitFaster.Caching/Lfu/ConcurrentTLfu.cs | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/BitFaster.Caching/Lfu/ConcurrentLfu.cs b/BitFaster.Caching/Lfu/ConcurrentLfu.cs index 45913871..86dce168 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfu.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfu.cs @@ -220,11 +220,8 @@ public string FormatLfuString() // To get JIT optimizations, policies must be structs. // If the structs are returned directly via properties, they will be copied. Since - // eventPolicy is a mutable struct, copy is bad. One workaround is to store the - // state within the struct in an object. Since the struct points to the same object - // it becomes immutable. However, this object is then somewhere else on the - // heap, which slows down the policies with hit counter logic in benchmarks. Likely - // this approach keeps the structs data members in the same CPU cache line as the LFU. + // eventPolicy is a mutable struct, copy is bad since changes are lost. + // Hence it is returned by ref and mutated via Proxy. private class Proxy : ICacheEvents { private readonly ConcurrentLfu lfu; diff --git a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs index 530b35df..46aa7a5d 100644 --- a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs +++ b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs @@ -201,11 +201,8 @@ public void TrimExpired() // To get JIT optimizations, policies must be structs. // If the structs are returned directly via properties, they will be copied. Since - // eventPolicy is a mutable struct, copy is bad. One workaround is to store the - // state within the struct in an object. Since the struct points to the same object - // it becomes immutable. However, this object is then somewhere else on the - // heap, which slows down the policies with hit counter logic in benchmarks. Likely - // this approach keeps the structs data members in the same CPU cache line as the LFU. + // eventPolicy is a mutable struct, copy is bad since changes are lost. + // Hence it is returned by ref and mutated via Proxy. private class Proxy : ICacheEvents { private readonly ConcurrentTLfu lfu; From c6f778d8daaf55f654e8bf645a7f4740da03e064 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Wed, 14 Jan 2026 11:35:48 -0800 Subject: [PATCH 12/12] TLfu tests --- .../Lfu/ConcurrentTLfuTests.cs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs index c4f67bd7..98077d4d 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Runtime.InteropServices; using System.Threading; using BitFaster.Caching.Lfu; @@ -21,6 +23,19 @@ public class ConcurrentTLfuTests // on MacOS time measurement seems to be less stable, give longer pause private int ttlWaitMlutiplier = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? 8 : 2; + private List> removedItems = new List>(); + private List> updatedItems = new List>(); + + private void OnLfuItemRemoved(object sender, ItemRemovedEventArgs e) + { + removedItems.Add(e); + } + + private void OnLfuItemUpdated(object sender, ItemUpdatedEventArgs e) + { + updatedItems.Add(e); + } + public ConcurrentTLfuTests() { lfu = new ConcurrentTLfu(capacity, new ExpireAfterWrite(timeToLive)); @@ -212,5 +227,154 @@ public void WhenItemIsUpdatedTtlIsExtended() } ); } + + [Fact] + public void WhenItemIsRemovedRemovedEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + lfuEvents.GetOrAdd(1, i => i + 2); + + lfuEvents.TryRemove(1).Should().BeTrue(); + + // Maintenance is needed for events to be processed + lfuEvents.DoMaintenance(); + + removedItems.Count.Should().Be(1); + removedItems[0].Key.Should().Be(1); + removedItems[0].Value.Should().Be(3); + removedItems[0].Reason.Should().Be(ItemRemovedReason.Removed); + } + + [Fact] + public void WhenItemRemovedEventIsUnregisteredEventIsNotFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + lfuEvents.Events.Value.ItemRemoved -= OnLfuItemRemoved; + + lfuEvents.GetOrAdd(1, i => i + 1); + lfuEvents.TryRemove(1); + lfuEvents.DoMaintenance(); + + removedItems.Count.Should().Be(0); + } + + [Fact] + public void WhenValueEvictedItemRemovedEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(6, new TestExpiryCalculator()); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + // Fill cache to capacity + for (int i = 0; i < 6; i++) + { + lfuEvents.GetOrAdd(i, i => i); + } + + // This should trigger eviction + lfuEvents.GetOrAdd(100, i => i); + lfuEvents.DoMaintenance(); + + // At least one item should be evicted + removedItems.Count.Should().BeGreaterThan(0); + removedItems.Any(r => r.Reason == ItemRemovedReason.Evicted).Should().BeTrue(); + } + + [Fact] + public void WhenItemsAreTrimmedAnEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + for (int i = 0; i < 6; i++) + { + lfuEvents.GetOrAdd(i, i => i); + } + + lfuEvents.Trim(2); + + removedItems.Count.Should().Be(2); + removedItems.All(r => r.Reason == ItemRemovedReason.Trimmed).Should().BeTrue(); + } + + [Fact] + public void WhenItemsAreClearedAnEventIsFired() + { + removedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + lfuEvents.Events.Value.ItemRemoved += OnLfuItemRemoved; + + for (int i = 0; i < 6; i++) + { + lfuEvents.GetOrAdd(i, i => i); + } + + lfuEvents.Clear(); + + removedItems.Count.Should().Be(6); + removedItems.All(r => r.Reason == ItemRemovedReason.Cleared).Should().BeTrue(); + } + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + [Fact] + public void WhenItemExistsAddOrUpdateFiresUpdateEvent() + { + updatedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + lfuEvents.Events.Value.ItemUpdated += OnLfuItemUpdated; + + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(2, 3); + + lfuEvents.AddOrUpdate(1, 3); + + updatedItems.Count.Should().Be(1); + updatedItems[0].Key.Should().Be(1); + updatedItems[0].OldValue.Should().Be(2); + updatedItems[0].NewValue.Should().Be(3); + } + + [Fact] + public void WhenItemExistsTryUpdateFiresUpdateEvent() + { + updatedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + lfuEvents.Events.Value.ItemUpdated += OnLfuItemUpdated; + + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(2, 3); + + lfuEvents.TryUpdate(1, 3); + + updatedItems.Count.Should().Be(1); + updatedItems[0].Key.Should().Be(1); + updatedItems[0].OldValue.Should().Be(2); + updatedItems[0].NewValue.Should().Be(3); + } + + [Fact] + public void WhenItemUpdatedEventIsUnregisteredEventIsNotFired() + { + updatedItems.Clear(); + var lfuEvents = new ConcurrentTLfu(20, new TestExpiryCalculator()); + + lfuEvents.Events.Value.ItemUpdated += OnLfuItemUpdated; + lfuEvents.Events.Value.ItemUpdated -= OnLfuItemUpdated; + + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(1, 2); + lfuEvents.AddOrUpdate(1, 2); + + updatedItems.Count.Should().Be(0); + } +#endif } }