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/ConcurrentLfuTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuTests.cs index a301a1bb..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; @@ -514,9 +527,9 @@ public void ExpireAfterWriteIsDisabled() } [Fact] - public void EventsAreDisabled() + public void EventsAreEnabled() { - cache.Events.HasValue.Should().BeFalse(); + cache.Events.HasValue.Should().BeTrue(); } [Fact] @@ -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.UnitTests/Lfu/ConcurrentTLfuTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentTLfuTests.cs index 328e5e02..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)); @@ -75,10 +90,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] @@ -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 } } 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.UnitTests/Lfu/TimerWheelTests.cs b/BitFaster.Caching.UnitTests/Lfu/TimerWheelTests.cs index 3300deac..4e0547b0 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); diff --git a/BitFaster.Caching/Lfu/ConcurrentLfu.cs b/BitFaster.Caching/Lfu/ConcurrentLfu.cs index 9038b593..86dce168 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,9 @@ private void DrainBuffers() public Optional Metrics => core.Metrics; /// - public Optional> Events => Optional>.None(); + public Optional> Events => new(new Proxy(this)); + + internal ref EventPolicy EventPolicyRef => ref this.core.eventPolicy; /// public CachePolicy Policy => core.Policy; @@ -116,6 +122,7 @@ public void AddOrUpdate(K key, V value) public void Clear() { core.Clear(); + DoMaintenance(); } /// @@ -146,6 +153,7 @@ public ValueTask GetOrAddAsync(K key, Func> valueFacto public void Trim(int itemCount) { core.Trim(itemCount); + DoMaintenance(); } /// @@ -210,6 +218,51 @@ 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 since changes are lost. + // Hence it is returned by ref and mutated via Proxy. + 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/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 0e4a3c3c..ff118f11 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/ @@ -143,7 +155,7 @@ public void AddOrUpdate(K key, V value) public void Clear() { - Trim(int.MaxValue); + Trim(int.MaxValue, ItemRemovedReason.Cleared); lock (maintenanceLock) { @@ -154,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 @@ -170,14 +187,13 @@ 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); } } @@ -373,6 +389,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 +400,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; } } @@ -544,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); @@ -581,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(); @@ -625,6 +650,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 @@ -637,6 +678,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 + RemoveEventInliner.OnRemovedEvent(this, node); this.metrics.evictedCount++; Disposer.Dispose(node.Value); node.WasDeleted = true; @@ -691,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() @@ -741,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); @@ -757,7 +799,7 @@ private void EvictFromMain(LfuNode candidateNode) if (victim.node == candidate.node) { - Evict(candidate.node!); + Evict(candidate.node!, reason); break; } @@ -765,7 +807,7 @@ private void EvictFromMain(LfuNode candidateNode) { var evictee = candidate.node; candidate.Next(); - Evict(evictee); + Evict(evictee, reason); continue; } @@ -773,7 +815,7 @@ private void EvictFromMain(LfuNode candidateNode) { var evictee = victim.node; victim.Next(); - Evict(evictee); + Evict(evictee, reason); continue; } @@ -786,7 +828,7 @@ private void EvictFromMain(LfuNode candidateNode) victim.Next(); candidate.Next(); - Evict(evictee); + Evict(evictee, reason); } else { @@ -795,7 +837,7 @@ private void EvictFromMain(LfuNode candidateNode) // candidate is initialized to first cand, and iterates forwards candidate.Next(); - Evict(evictee); + Evict(evictee, reason); } } @@ -807,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); } } } @@ -825,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; @@ -839,6 +881,7 @@ internal void Evict(LfuNode evictee) ((ICollection>)this.dictionary).Remove(kvp); #endif evictee.list?.Remove(evictee); + this.eventPolicy.OnItemRemoved(evictee.Key, evictee.Value, reason); Disposer.Dispose(evictee.Value); this.metrics.evictedCount++; diff --git a/BitFaster.Caching/Lfu/ConcurrentTLfu.cs b/BitFaster.Caching/Lfu/ConcurrentTLfu.cs index 439fddf1..46aa7a5d 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,9 @@ private void DrainBuffers() public Optional Metrics => core.Metrics; /// - public Optional> Events => Optional>.None(); + public Optional> Events => new(new Proxy(this)); + + internal ref EventPolicy EventPolicyRef => ref this.core.eventPolicy; /// public CachePolicy Policy => CreatePolicy(); @@ -67,6 +73,7 @@ public void AddOrUpdate(K key, V value) public void Clear() { core.Clear(); + DoMaintenance(); } /// @@ -97,6 +104,7 @@ public ValueTask GetOrAddAsync(K key, Func> valueFacto public void Trim(int itemCount) { core.Trim(itemCount); + DoMaintenance(); } /// @@ -190,5 +198,50 @@ 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 since changes are lost. + // Hence it is returned by ref and mutated via Proxy. + 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 new file mode 100644 index 00000000..d29608d9 --- /dev/null +++ b/BitFaster.Caching/Lfu/EventPolicy.cs @@ -0,0 +1,44 @@ +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}")] + internal struct EventPolicy : IEventPolicy + where K : notnull + { + private object eventSource; + + /// + public event EventHandler> ItemRemoved; + + /// + public event EventHandler> ItemUpdated; + + /// + public void OnItemRemoved(K key, V value, ItemRemovedReason reason) + { + // 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) + { + // 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.eventSource = source; + } + } +} diff --git a/BitFaster.Caching/Lfu/IEventPolicy.cs b/BitFaster.Caching/Lfu/IEventPolicy.cs new file mode 100644 index 00000000..a1812e93 --- /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. + internal 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..414e4a03 --- /dev/null +++ b/BitFaster.Caching/Lfu/NoEventPolicy.cs @@ -0,0 +1,49 @@ +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. + internal struct NoEventPolicy : IEventPolicy + where K : notnull + { + /// + 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..180e9747 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,9 +140,9 @@ 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()); + wheel.Advance, P, TimeOrderNode, E>(ref cache, Duration.SinceEpoch()); } } } diff --git a/BitFaster.Caching/Lfu/TimerWheel.cs b/BitFaster.Caching/Lfu/TimerWheel.cs index cc4675a5..ee74fb30 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 { @@ -61,9 +62,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; @@ -90,7 +92,7 @@ public void Advance(ref ConcurrentLfuCore cache, Duration curr break; } - Expire(ref cache, i, previousTicks, delta); + Expire(ref cache, i, previousTicks, delta); } } catch (Exception) @@ -101,9 +103,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; @@ -132,7 +135,7 @@ private void Expire(ref ConcurrentLfuCore cache, int index, lo { if ((node.GetTimestamp() - time) < 0) { - cache.Evict(node); + cache.Evict(node, ItemRemovedReason.Evicted); } else {