Reputation: 8599
I have a very simple Code, but what it does it completely weird. It is a simple Cache abstraction and goes like this:
public class CacheAbstraction
{
private MemoryCache _cache;
public CacheAbstraction()
{
_cache = new MemoryCache(new MemoryCacheOptions { });
}
public async Task<T> GetItemAsync<T>(TimeSpan duration, Func<Task<T>> factory,
[CallerMemberName] string identifier = null ) where T : class
{
return await _cache.GetOrCreateAsync<T>(identifier, async x =>
{
x.SetAbsoluteExpiration(DateTime.UtcNow.Add(duration));
T result = null;
result = await factory();
return result;
});
}
}
Now the fun part: I'm passing expiration durations of 1h - 1d
If I run it in a test suite, everything is fine.
If I run it as a .net core app, the expiration is always set to "now" and the item expires on the next cache check. WTF!?
Upvotes: 1
Views: 2808
Reputation: 3950
I know it's been two years, but I ran across this same problem (cache items seeming to expire instantly) recently and found a possible cause. Two essentially undocumented features in MemoryCache
: linked cache entries and options propagation.
This allows a child cache entry object to passively propagate it's options up to a parent cache entry when the child goes out of scope. This is done via IDisposable
, which ICacheEntry
implements and is used internally by MemoryCache
in extension methods like Set()
and GetOrCreate/Async()
. What this means is that if you have "nested" cache operations, the inner ones will propagate their cache entry options to the outer ones, including cancellation tokens, expiration callbacks, and expiration times.
In my case, we were using GetOrCreateAsync()
and a factory method that made use of a library which did its own caching using the same injected IMemoryCache
. For example:
public async Task<Foo> GetFooAsync() {
return await _cache.GetOrCreateAsync("cacheKey", async c => {
c.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await _library.DoSomething();
});
}
The library uses IMemoryCache
internally (the same instance, injected via DI) to cache results for a few seconds, essentially doing this:
_cache.Set(queryKey, queryResult, TimeSpan.FromSeconds(5));
Because GetOrCreateAsync()
is implemented by creating a CacheEntry
inside a using
block, the effect is that the 5 second expiration used by the library propagates up to the parent cache entry in GetFooAsync()
, resulting in the Foo object always only being cached for 5 seconds instead of 1 hour, effectively expiring it immediately.
DotNet Fiddle showing this behavior: https://dotnetfiddle.net/fo14BT
You can avoid this propagation behavior in a few ways:
(1) Use TryGetValue()
and Set()
instead of GetOrCreateAsync()
if (_cache.TryGetValue("cacheKey", out Foo result))
return result;
result = await _library.DoSomething();
return _cache.Set("cacheKey", result, TimeSpan.FromHours(1));
(2) Assign the cache entry options after invoking the other code that may also use the cache
return await _cache.GetOrCreateAsync("cacheKey", async c => {
var result = await _library.DoSomething();
// set expiration *after*
c.AbsoluteExpiration = DateTime.Now.AddHours(1);
return result;
});
(and since GetOrCreate/Async()
does not prevent reentrancy, the two are effectively the same from a concurrency standpoint).
Warning: Even then it's easy to get wrong. If you try to use AbsoluteExpirationRelativeToNow
in option (2) it won't work because setting that property doesn't remove the AbsoluteExpiration
value if it exists resulting in both properties having a value in the CacheEntry
, and AbsoluteExpiration
is honored before the relative.
For the future, Microsoft has added a feature to control this behavior via a new property MemoryCacheOptions.TrackLinkedCacheEntries
, but it won't be available until .NET 7. Without this future feature, I haven't been able to think of a way for libraries to prevent propagation, aside from using a different MemoryCache
instance.
Upvotes: 1