From 218bd2352b1217c9bacdf35566c19ebb3c85c7fa Mon Sep 17 00:00:00 2001 From: Alastair Crabtree Date: Sat, 19 Sep 2020 19:51:48 +0100 Subject: [PATCH] feat: allow passing MemoryCacheEntryOptions at cache insertion time and add immediate cache removal - Add options for expiration: - ExpirationMode.ImmediateExpiration which uses a timer to remove items from the cache as soon as they expire (more resource intensive) - ExpirationMode.LazyExpiration (existing default) which removes expired cache items when they are next accessed if they have expired. - Fix #96 AddExpirationToken with CancellationChangeToken is not being honored - Allow callers to pass MemoryCacheEntryOptions that is used at cache insertion time. This allows users to wire up callbacks and expiration tokens that fire at the correct time --- .../CachingServiceMemoryCacheProviderTests.cs | 79 +++++++++++++++++++ LazyCache/AppCacheExtensions.cs | 46 ++++++++--- LazyCache/CachingService.cs | 17 +++- LazyCache/ExpirationMode.cs | 20 +++++ LazyCache/IAppCache.cs | 10 +-- LazyCache/ICacheProvider.cs | 1 + .../MemoryCacheEntryOptionsExtensions.cs | 57 +++++++++++++ LazyCache/Mocks/MockCacheProvider.cs | 5 ++ LazyCache/Mocks/MockCachingService.cs | 11 +++ LazyCache/Providers/MemoryCacheProvider.cs | 40 ++++++++++ 10 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 LazyCache/ExpirationMode.cs create mode 100644 LazyCache/MemoryCacheEntryOptionsExtensions.cs diff --git a/LazyCache.UnitTests/CachingServiceMemoryCacheProviderTests.cs b/LazyCache.UnitTests/CachingServiceMemoryCacheProviderTests.cs index 4a91aa5..89c40b7 100644 --- a/LazyCache.UnitTests/CachingServiceMemoryCacheProviderTests.cs +++ b/LazyCache.UnitTests/CachingServiceMemoryCacheProviderTests.cs @@ -876,6 +876,85 @@ public async Task GetOrAddAsyncWithCancellationExpiryBasedOnTimerInTheDelegateDo Assert.That(expiredResult, Is.Null); } + [Test] + public void GetOrAddWithCancellationExpiryBasedOnTimerAndCallbackInTheDelegateDoesExpireItemsAndFireTheCallback() + { + var millisecondsCacheDuration = 100; + var callbackHasFired = false; + var tokenSource = new CancellationTokenSource(millisecondsCacheDuration); + var expireToken = new CancellationChangeToken(tokenSource.Token); + var validResult = sut.GetOrAdd( + TestKey, + entry => + { + entry.RegisterPostEvictionCallback((key, value, reason, state) => callbackHasFired = true); + return new ComplexTestObject(); + }, new MemoryCacheEntryOptions() + .AddExpirationToken(expireToken)); + // trigger expiry + Thread.Sleep(TimeSpan.FromMilliseconds(millisecondsCacheDuration + 50)); + + Assert.That(validResult, Is.Not.Null); + Assert.That(callbackHasFired, Is.True); + } + + [Test] + public void GetOrAddWithImmediateExpirationAndCallbackInTheDelegateDoesExpireItemsAndFireTheCallback() + { + var millisecondsCacheDuration = 100; + var callbackHasFired = false; + var validResult = sut.GetOrAdd( + TestKey, + entry => + { + entry.RegisterPostEvictionCallback((key, value, reason, state) => callbackHasFired = true); + return new ComplexTestObject(); + }, LazyCacheEntryOptions.WithImmediateAbsoluteExpiration(TimeSpan.FromMilliseconds(millisecondsCacheDuration))); + // trigger expiry + Thread.Sleep(TimeSpan.FromMilliseconds(millisecondsCacheDuration + 50)); + + Assert.That(validResult, Is.Not.Null); + Assert.That(callbackHasFired, Is.True); + } + + [Test] + public async Task GetOrAddAsyncWithImmediateExpirationAndCallbackInTheDelegateDoesExpireItemsAndFireTheCallback() + { + var millisecondsCacheDuration = 100; + var callbackHasFired = false; + var validResult = await sut.GetOrAddAsync( + TestKey, + entry => + { + entry.RegisterPostEvictionCallback((key, value, reason, state) => callbackHasFired = true); + return Task.FromResult(new ComplexTestObject()); + }, LazyCacheEntryOptions.WithImmediateAbsoluteExpiration(TimeSpan.FromMilliseconds(millisecondsCacheDuration))); + // trigger expiry + Thread.Sleep(TimeSpan.FromMilliseconds(millisecondsCacheDuration + 50)); + + Assert.That(validResult, Is.Not.Null); + Assert.That(callbackHasFired, Is.True); + } + + [Test] + public async Task GetOrAddAsyncWithImmediateExpirationDoesExpireItems() + { + var millisecondsCacheDuration = 100; + var validResult = await sut.GetOrAddAsync( + TestKey, + () => + { + return Task.FromResult(new ComplexTestObject()); + }, DateTimeOffset.UtcNow.AddMilliseconds(millisecondsCacheDuration), ExpirationMode.ImmediateExpiration); + // trigger expiry + Thread.Sleep(TimeSpan.FromMilliseconds(millisecondsCacheDuration + 50)); + + var actual = sut.Get(TestKey); + + Assert.That(validResult, Is.Not.Null); + Assert.That(actual, Is.Null); + } + [Test] public void GetOrAddWithPolicyAndThenGetObjectReturnsCorrectType() { diff --git a/LazyCache/AppCacheExtensions.cs b/LazyCache/AppCacheExtensions.cs index 7ea169b..5e7f0d2 100644 --- a/LazyCache/AppCacheExtensions.cs +++ b/LazyCache/AppCacheExtensions.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Primitives; namespace LazyCache { @@ -41,6 +43,21 @@ public static T GetOrAdd(this IAppCache cache, string key, Func addItemFac return cache.GetOrAdd(key, addItemFactory, new MemoryCacheEntryOptions {AbsoluteExpiration = expires}); } + public static T GetOrAdd(this IAppCache cache, string key, Func addItemFactory, DateTimeOffset expires, ExpirationMode mode) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + + switch (mode) + { + case ExpirationMode.LazyExpiration: + return cache.GetOrAdd(key, addItemFactory, new MemoryCacheEntryOptions { AbsoluteExpiration = expires }); + case ExpirationMode.ImmediateExpiration: + return cache.GetOrAdd(key, addItemFactory, LazyCacheEntryOptions.WithImmediateAbsoluteExpiration(expires)); + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, null); + } + } + public static T GetOrAdd(this IAppCache cache, string key, Func addItemFactory, TimeSpan slidingExpiration) { @@ -53,11 +70,7 @@ public static T GetOrAdd(this IAppCache cache, string key, Func addItemFac { if (cache == null) throw new ArgumentNullException(nameof(cache)); - return cache.GetOrAdd(key, entry => - { - entry.SetOptions(policy); - return addItemFactory(); - }); + return cache.GetOrAdd(key, _=> addItemFactory(), policy); } public static Task GetOrAddAsync(this IAppCache cache, string key, Func> addItemFactory) @@ -67,7 +80,6 @@ public static Task GetOrAddAsync(this IAppCache cache, string key, Func GetOrAddAsync(this IAppCache cache, string key, Func> addItemFactory, DateTimeOffset expires) { @@ -76,6 +88,22 @@ public static Task GetOrAddAsync(this IAppCache cache, string key, Func GetOrAddAsync(this IAppCache cache, string key, Func> addItemFactory, + DateTimeOffset expires, ExpirationMode mode) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + + switch (mode) + { + case ExpirationMode.LazyExpiration: + return cache.GetOrAddAsync(key, addItemFactory, new MemoryCacheEntryOptions { AbsoluteExpiration = expires }); + case ExpirationMode.ImmediateExpiration: + return cache.GetOrAddAsync(key, addItemFactory, new LazyCacheEntryOptions().SetAbsoluteExpiration(expires,mode)); + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, null); + } + } + public static Task GetOrAddAsync(this IAppCache cache, string key, Func> addItemFactory, TimeSpan slidingExpiration) { @@ -90,11 +118,7 @@ public static Task GetOrAddAsync(this IAppCache cache, string key, Func - { - entry.SetOptions(policy); - return addItemFactory(); - }); + return cache.GetOrAddAsync(key, _=> addItemFactory(), policy); } } } \ No newline at end of file diff --git a/LazyCache/CachingService.cs b/LazyCache/CachingService.cs index 983fad2..249aecf 100644 --- a/LazyCache/CachingService.cs +++ b/LazyCache/CachingService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using LazyCache.Providers; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Primitives; namespace LazyCache { @@ -84,6 +85,11 @@ public virtual Task GetAsync(string key) } public virtual T GetOrAdd(string key, Func addItemFactory) + { + return GetOrAdd(key, addItemFactory, null); + } + + public virtual T GetOrAdd(string key, Func addItemFactory, MemoryCacheEntryOptions policy) { ValidateKey(key); @@ -101,7 +107,7 @@ object CacheFactory(ICacheEntry entry) => locker.Wait(); //TODO: do we really need this? Could we just lock on the key? like this? https://github.com/zkSNACKs/WalletWasabi/blob/7780db075685d2dc13620e0bcf6cc07578b627c2/WalletWasabi/Extensions/MemoryExtensions.cs try { - cacheItem = CacheProvider.GetOrCreate(key, CacheFactory); + cacheItem = CacheProvider.GetOrCreate(key, policy, CacheFactory); } finally { @@ -110,7 +116,7 @@ object CacheFactory(ICacheEntry entry) => try { - var result = GetValueFromLazy(cacheItem, out var valueHasChangedType); + var result = GetValueFromLazy(cacheItem, out var valueHasChangedType); // if we get a cache hit but for something with the wrong type we need to evict it, start again and cache the new item instead if (valueHasChangedType) @@ -156,6 +162,11 @@ public virtual void Remove(string key) public virtual async Task GetOrAddAsync(string key, Func> addItemFactory) { + return await GetOrAddAsync(key, addItemFactory, null); + } + public virtual async Task GetOrAddAsync(string key, Func> addItemFactory, + MemoryCacheEntryOptions policy) + { ValidateKey(key); object cacheItem; @@ -180,7 +191,7 @@ object CacheFactory(ICacheEntry entry) => try { - cacheItem = CacheProvider.GetOrCreate(key, CacheFactory); + cacheItem = CacheProvider.GetOrCreate(key, policy, CacheFactory); } finally { diff --git a/LazyCache/ExpirationMode.cs b/LazyCache/ExpirationMode.cs new file mode 100644 index 0000000..18af195 --- /dev/null +++ b/LazyCache/ExpirationMode.cs @@ -0,0 +1,20 @@ +namespace LazyCache +{ + public enum ExpirationMode + { + /// + /// This is the default for Memory cache - expired items are removed from the cache + /// the next time that key is accessed. This is the most performant, and so the default, + /// because no timers are required to removed expired items, but it does mean that + /// PostEvictionCallbacks may fire later than expected, or not at all. + /// + LazyExpiration, + + /// + /// Use a timer to force eviction of expired items from the cache as soon as they expire. + /// This will then trigger PostEvictionCallbacks at the expected time. This uses more resources + /// than LazyExpiration. + /// + ImmediateExpiration + } +} \ No newline at end of file diff --git a/LazyCache/IAppCache.cs b/LazyCache/IAppCache.cs index b474cbe..b171411 100644 --- a/LazyCache/IAppCache.cs +++ b/LazyCache/IAppCache.cs @@ -12,17 +12,13 @@ public interface IAppCache /// Define the number of seconds to cache objects for by default /// CacheDefaults DefaultCachePolicy { get; } - void Add(string key, T item, MemoryCacheEntryOptions policy); - T Get(string key); - - T GetOrAdd(string key, Func addItemFactory); - Task GetAsync(string key); - + T GetOrAdd(string key, Func addItemFactory); + T GetOrAdd(string key, Func addItemFactory, MemoryCacheEntryOptions policy); Task GetOrAddAsync(string key, Func> addItemFactory); - + Task GetOrAddAsync(string key, Func> addItemFactory, MemoryCacheEntryOptions policy); void Remove(string key); } } \ No newline at end of file diff --git a/LazyCache/ICacheProvider.cs b/LazyCache/ICacheProvider.cs index 2007416..83885d6 100644 --- a/LazyCache/ICacheProvider.cs +++ b/LazyCache/ICacheProvider.cs @@ -9,6 +9,7 @@ public interface ICacheProvider : IDisposable void Set(string key, object item, MemoryCacheEntryOptions policy); object Get(string key); object GetOrCreate(string key, Func func); + object GetOrCreate(string key, MemoryCacheEntryOptions policy, Func func); void Remove(string key); Task GetOrCreateAsync(string key, Func> func); } diff --git a/LazyCache/MemoryCacheEntryOptionsExtensions.cs b/LazyCache/MemoryCacheEntryOptionsExtensions.cs new file mode 100644 index 0000000..b3cf68e --- /dev/null +++ b/LazyCache/MemoryCacheEntryOptionsExtensions.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.Extensions.Caching.Memory; + +namespace LazyCache +{ + public class LazyCacheEntryOptions : MemoryCacheEntryOptions + { + public ExpirationMode ExpirationMode { get; set; } + public TimeSpan ImmediateAbsoluteExpirationRelativeToNow { get; set; } + + public static LazyCacheEntryOptions WithImmediateAbsoluteExpiration(DateTimeOffset absoluteExpiration) + { + var delay = absoluteExpiration.Subtract(DateTimeOffset.UtcNow); + return new LazyCacheEntryOptions + { + AbsoluteExpiration = absoluteExpiration, + ExpirationMode = ExpirationMode.ImmediateExpiration, + ImmediateAbsoluteExpirationRelativeToNow = delay + }; + } + + public static LazyCacheEntryOptions WithImmediateAbsoluteExpiration(TimeSpan absoluteExpiration) + { + return new LazyCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = absoluteExpiration, + ExpirationMode = ExpirationMode.ImmediateExpiration, + ImmediateAbsoluteExpirationRelativeToNow = absoluteExpiration + }; + } + } + + public static class LazyCacheEntryOptionsExtension { + public static LazyCacheEntryOptions SetAbsoluteExpiration(this LazyCacheEntryOptions option, DateTimeOffset absoluteExpiration, + ExpirationMode mode) + { + if (option == null) throw new ArgumentNullException(nameof(option)); + + var delay = absoluteExpiration.Subtract(DateTimeOffset.UtcNow); + option.AbsoluteExpiration = absoluteExpiration; + option.ExpirationMode = mode; + option.ImmediateAbsoluteExpirationRelativeToNow = delay; + return option; + } + + public static LazyCacheEntryOptions SetAbsoluteExpiration(this LazyCacheEntryOptions option, TimeSpan absoluteExpiration, + ExpirationMode mode) + { + if (option == null) throw new ArgumentNullException(nameof(option)); + + option.AbsoluteExpirationRelativeToNow = absoluteExpiration; + option.ExpirationMode = mode; + option.ImmediateAbsoluteExpirationRelativeToNow = absoluteExpiration; + return option; + } + } +} \ No newline at end of file diff --git a/LazyCache/Mocks/MockCacheProvider.cs b/LazyCache/Mocks/MockCacheProvider.cs index c7463e7..e8e2691 100644 --- a/LazyCache/Mocks/MockCacheProvider.cs +++ b/LazyCache/Mocks/MockCacheProvider.cs @@ -20,6 +20,11 @@ public object GetOrCreate(string key, Func func) return func(null); } + public object GetOrCreate(string key, MemoryCacheEntryOptions policy, Func func) + { + return func(null); + } + public void Remove(string key) { } diff --git a/LazyCache/Mocks/MockCachingService.cs b/LazyCache/Mocks/MockCachingService.cs index 51b92fd..71016c9 100644 --- a/LazyCache/Mocks/MockCachingService.cs +++ b/LazyCache/Mocks/MockCachingService.cs @@ -23,6 +23,17 @@ public T GetOrAdd(string key, Func addItemFactory) return addItemFactory(new MockCacheEntry(key)); } + public T GetOrAdd(string key, Func addItemFactory, MemoryCacheEntryOptions policy) + { + return addItemFactory(new MockCacheEntry(key)); + } + + public Task GetOrAddAsync(string key, Func> addItemFactory, + MemoryCacheEntryOptions policy) + { + return addItemFactory(new MockCacheEntry(key)); + } + public void Remove(string key) { } diff --git a/LazyCache/Providers/MemoryCacheProvider.cs b/LazyCache/Providers/MemoryCacheProvider.cs index 9bdfdc9..cacd12e 100644 --- a/LazyCache/Providers/MemoryCacheProvider.cs +++ b/LazyCache/Providers/MemoryCacheProvider.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Primitives; namespace LazyCache.Providers { @@ -28,6 +30,44 @@ public object GetOrCreate(string key, Func factory) return cache.GetOrCreate(key, factory); } + public object GetOrCreate(string key, MemoryCacheEntryOptions policy, Func factory) + { + if(policy == null) + return cache.GetOrCreate(key, factory); + + if (!cache.TryGetValue(key, out object result)) + { + var entry = cache.CreateEntry(key); + // Set the initial options before the factory is fired so that any callbacks + // that need to be wired up are still added. + entry.SetOptions(policy); + + if (policy is LazyCacheEntryOptions lazyPolicy && lazyPolicy.ExpirationMode == ExpirationMode.ImmediateExpiration) + { + var expiryTokenSource = new CancellationTokenSource(); + var expireToken = new CancellationChangeToken(expiryTokenSource.Token); + entry.AddExpirationToken(expireToken); + entry.RegisterPostEvictionCallback((keyPost, value, reason, state) => + expiryTokenSource.Dispose()); + + result = factory(entry); + + expiryTokenSource.CancelAfter(lazyPolicy.ImmediateAbsoluteExpirationRelativeToNow); + } + else + { + result = factory(entry); + } + entry.SetValue(result); + // need to manually call dispose instead of having a using + // in case the factory passed in throws, in which case we + // do not want to add the entry to the cache + entry.Dispose(); + } + + return (T)result; + } + public void Remove(string key) { cache.Remove(key);