Skip to content

Commit 9cec4ea

Browse files
committed
Implement ExpirationCallback functionality on each CacheEntity in addition to the generic callback specified in the config +semver: minor
1 parent 1a8c277 commit 9cec4ea

File tree

6 files changed

+107
-51
lines changed

6 files changed

+107
-51
lines changed

AsyncMemoryCache.Tests/CacheEntityReferenceExtensionTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,17 @@ public void WithExpirationStrategyExtension()
5050

5151
Assert.Equal(expirationStrategy, cacheEntity.ExpirationStrategy);
5252
}
53+
54+
[Fact]
55+
public void WithExpirationCallbackExtension()
56+
{
57+
var expirationCallback = (string key, IAsyncDisposable obj) => { };
58+
59+
var cacheEntity = new CacheEntity<string, IAsyncDisposable>("test", () => Task.FromResult((IAsyncDisposable)null!), AsyncLazyFlags.None);
60+
var cacheEntityReference = new CacheEntityReference<string, IAsyncDisposable>(cacheEntity);
61+
62+
cacheEntityReference.WithExpirationCallback(expirationCallback);
63+
64+
Assert.Equal(expirationCallback, cacheEntity.ExpirationCallback);
65+
}
5366
}

AsyncMemoryCache.Tests/EvictionBehaviorTests.cs

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,13 @@ public async Task DefaultEvictionBehavior_RemovesAbsoluteExpiredItem_LeavesNonEx
6666
const string expiredKey = "expired";
6767
const string notExpiredKey = "notExpired";
6868

69-
var cache = new Dictionary<string, CacheEntity<string, IAsyncDisposable>>
70-
{
71-
{
72-
notExpiredKey, new CacheEntity<string, IAsyncDisposable>(notExpiredKey, () => Task.FromResult(notExpiredCacheObject), AsyncLazyFlags.None)
73-
.WithAbsoluteExpiration(DateTimeOffset.UtcNow.AddDays(2))
74-
},
75-
{
76-
expiredKey, new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
77-
.WithAbsoluteExpiration(DateTimeOffset.UtcNow.AddMinutes(-1))
78-
}
79-
};
69+
var expiredItem = new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
70+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow.AddMinutes(-1));
8071

81-
await Internal_DefaultEvictionBehavior_RemovesExpiredItem_LeavesNonExpiredItems(cache, expiredKey, notExpiredKey, expiredCacheObject, notExpiredCacheObject);
72+
var notExpiredItem = new CacheEntity<string, IAsyncDisposable>(notExpiredKey, () => Task.FromResult(notExpiredCacheObject), AsyncLazyFlags.None)
73+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow.AddDays(2));
74+
75+
await Internal_DefaultEvictionBehavior_RemovesExpiredItem_LeavesNonExpiredItems(expiredItem, notExpiredItem, expiredCacheObject, notExpiredCacheObject);
8276
}
8377

8478
[Fact]
@@ -90,63 +84,48 @@ public async Task DefaultEvictionBehavior_RemovesSlidingExpiredItem_LeavesNonExp
9084
const string expiredKey = "expired";
9185
const string notExpiredKey = "notExpired";
9286

93-
var cache = new Dictionary<string, CacheEntity<string, IAsyncDisposable>>
94-
{
95-
{
96-
notExpiredKey, new CacheEntity<string, IAsyncDisposable>(notExpiredKey, () => Task.FromResult(notExpiredCacheObject), AsyncLazyFlags.None)
97-
.WithSlidingExpiration(TimeSpan.FromDays(2))
98-
},
99-
{
100-
expiredKey, new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
101-
.WithSlidingExpiration(TimeSpan.FromTicks(1))
102-
}
103-
};
87+
var expiredItem = new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
88+
.WithSlidingExpiration(TimeSpan.FromTicks(1));
89+
90+
var notExpiredItem = new CacheEntity<string, IAsyncDisposable>(notExpiredKey, () => Task.FromResult(notExpiredCacheObject), AsyncLazyFlags.None)
91+
.WithSlidingExpiration(TimeSpan.FromDays(2));
10492

105-
await Internal_DefaultEvictionBehavior_RemovesExpiredItem_LeavesNonExpiredItems(cache, expiredKey, notExpiredKey, expiredCacheObject, notExpiredCacheObject);
93+
await Internal_DefaultEvictionBehavior_RemovesExpiredItem_LeavesNonExpiredItems(expiredItem, notExpiredItem, expiredCacheObject, notExpiredCacheObject);
10694
}
10795

10896
private static async Task Internal_DefaultEvictionBehavior_RemovesExpiredItem_LeavesNonExpiredItems(
109-
Dictionary<string, CacheEntity<string, IAsyncDisposable>> cache,
110-
string expiredKey,
111-
string notExpiredKey,
97+
CacheEntity<string, IAsyncDisposable> expiredItem,
98+
CacheEntity<string, IAsyncDisposable> notExpiredItem,
11299
IAsyncDisposable expiredCacheObject,
113100
IAsyncDisposable notExpiredCacheObject)
114101
{
115102
var timeProvider = new FakeTimeProvider(DateTime.UtcNow);
116-
117103
var target = new DefaultEvictionBehavior(timeProvider);
118104

119-
var evt = new ManualResetEvent(false);
105+
var resetEvent = new ManualResetEvent(false);
106+
var cache = Substitute.For<IDictionary<string, CacheEntity<string, IAsyncDisposable>>>();
107+
_ = cache.Configure()
108+
.Values
109+
.Returns([expiredItem, notExpiredItem])
110+
.AndDoes(_ => resetEvent.Set());
111+
112+
cache.Remove(Arg.Any<string>()).Returns(true);
120113

121-
var expiredCacheItems = new Dictionary<string, IAsyncDisposable>();
122114
var config = new AsyncMemoryCacheConfiguration<string, IAsyncDisposable>
123115
{
124-
CacheItemExpired = async (s, item) =>
125-
{
126-
expiredCacheItems[s] = item;
127-
128-
// Bit of a hack
129-
// We need to wait for it to complete one tick, and DisposeAsync() waits for the worker task to complete
130-
// And as such guarantees at least one tick completion
131-
await target.DisposeAsync();
132-
_ = evt.Set();
133-
},
134116
CacheBackingStore = cache
135117
};
136118

137119
target.Start(config, _logger);
138120

139121
timeProvider.Advance(TimeSpan.FromSeconds(30));
140122

141-
_ = evt.WaitOne();
142-
143-
Assert.True(cache.ContainsKey(notExpiredKey));
144-
Assert.False(cache.ContainsKey(expiredKey));
145-
146-
Assert.True(expiredCacheItems.ContainsKey(expiredKey));
147-
Assert.False(expiredCacheItems.ContainsKey(notExpiredKey));
123+
// Will ensure that the internal loop has completed at least one tick
124+
_ = resetEvent.WaitOne();
125+
await target.DisposeAsync();
148126

149-
Assert.Same(expiredCacheObject, expiredCacheItems[expiredKey]);
127+
cache.Received(1).Remove(expiredItem.Key);
128+
cache.DidNotReceive().Remove(notExpiredItem.Key);
150129

151130
await expiredCacheObject.Received(1).DisposeAsync();
152131
await notExpiredCacheObject.DidNotReceive().DisposeAsync();
@@ -277,4 +256,52 @@ public async Task CacheItemWithoutExpirationStrategy_NeverDisposed()
277256

278257
await expiredCacheObject.DidNotReceive().DisposeAsync();
279258
}
259+
260+
[Fact]
261+
public async Task DefaultEvictionBehavior_CallsCallbacks_ForExpiredItem()
262+
{
263+
const string expiredKey = "expired";
264+
265+
var resetEvent = new ManualResetEvent(false);
266+
var globalCacheItemExpiredCallbackCalled = false;
267+
var globalCacheItemExpiredCallback = (string key, IAsyncDisposable value) => { globalCacheItemExpiredCallbackCalled = true; };
268+
var cacheItemExpirationCallbackCalled = false;
269+
var cacheItemExpirationCallback = (string key, IAsyncDisposable value) =>
270+
{
271+
cacheItemExpirationCallbackCalled = true;
272+
resetEvent.Set();
273+
};
274+
275+
var expiredCacheObject = Substitute.For<IAsyncDisposable>();
276+
var cache = Substitute.For<IDictionary<string, CacheEntity<string, IAsyncDisposable>>>();
277+
_ = cache.Configure()
278+
.Values
279+
.Returns(
280+
[
281+
new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
282+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow)
283+
.WithExpirationCallback(cacheItemExpirationCallback)
284+
])
285+
.AndDoes(_ => resetEvent.Set());
286+
287+
cache.Remove(expiredKey).Returns(true);
288+
289+
var timeProvider = new FakeTimeProvider(DateTime.UtcNow);
290+
var target = new DefaultEvictionBehavior(timeProvider);
291+
var config = new AsyncMemoryCacheConfiguration<string, IAsyncDisposable>
292+
{
293+
CacheItemExpired = globalCacheItemExpiredCallback,
294+
CacheBackingStore = cache
295+
};
296+
297+
target.Start(config, _logger);
298+
299+
timeProvider.Advance(TimeSpan.FromSeconds(30));
300+
resetEvent.WaitOne();
301+
302+
await target.DisposeAsync();
303+
304+
Assert.True(cacheItemExpirationCallbackCalled);
305+
Assert.True(globalCacheItemExpiredCallbackCalled);
306+
}
280307
}

AsyncMemoryCache/AsyncMemoryCacheConfiguration.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ public interface IAsyncMemoryCacheConfiguration<TKey, TValue>
1010
where TKey : notnull
1111
where TValue : IAsyncDisposable
1212
{
13-
IDictionary<TKey, CacheEntity<TKey, TValue>> CacheBackingStore { get; init; }
1413
Action<TKey, TValue>? CacheItemExpired { get; init; }
1514
IEvictionBehavior EvictionBehavior { get; init; }
15+
IDictionary<TKey, CacheEntity<TKey, TValue>> CacheBackingStore { get; init; }
1616
}
1717

1818
public sealed class AsyncMemoryCacheConfiguration<TKey, TValue> : IAsyncMemoryCacheConfiguration<TKey, TValue>

AsyncMemoryCache/CacheEntity.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public CacheEntity(TKey key, Func<Task<TValue>> objectFactory, AsyncLazyFlags la
2121
private int _references;
2222
internal ref int References => ref _references;
2323
internal IExpirationStrategy? ExpirationStrategy { get; private set; }
24+
internal Action<TKey, TValue>? ExpirationCallback { get; private set; }
2425

2526
public CacheEntity<TKey, TValue> WithAbsoluteExpiration(DateTimeOffset expiryDate)
2627
{
@@ -39,4 +40,10 @@ public CacheEntity<TKey, TValue> WithExpirationStrategy(IExpirationStrategy expi
3940
ExpirationStrategy = expirationStrategy;
4041
return this;
4142
}
43+
44+
public CacheEntity<TKey, TValue> WithExpirationCallback(Action<TKey, TValue> expirationCallback)
45+
{
46+
ExpirationCallback = expirationCallback;
47+
return this;
48+
}
4249
}

AsyncMemoryCache/EvictionBehaviors/DefaultEvictionBehavior.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,10 @@ private static async Task CheckExpiredItems<TKey, TValue>(IAsyncMemoryCacheConfi
8888
{
8989
logger.LogTrace("Expiring item with key {Key}", expiredItem.Key);
9090
var item = await expiredItem.ObjectFactory;
91-
if (cache.Remove(expiredItem.Key) && configuration.CacheItemExpired is not null)
91+
if (cache.Remove(expiredItem.Key))
9292
{
93-
configuration.CacheItemExpired.Invoke(expiredItem.Key, item);
93+
configuration.CacheItemExpired?.Invoke(expiredItem.Key, item);
94+
expiredItem.ExpirationCallback?.Invoke(expiredItem.Key, item);
9495
}
9596

9697
await item.DisposeAsync().ConfigureAwait(false);

AsyncMemoryCache/Extensions/CacheEntityReferenceExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,12 @@ public static CacheEntityReference<TKey, TValue> WithExpirationStrategy<TKey, TV
2828
_ = cacheEntityReference.CacheEntity.WithExpirationStrategy(expirationStrategy);
2929
return cacheEntityReference;
3030
}
31+
32+
public static CacheEntityReference<TKey, TValue> WithExpirationCallback<TKey, TValue>(this CacheEntityReference<TKey, TValue> cacheEntityReference, Action<TKey, TValue> expirationCallback)
33+
where TKey : notnull
34+
where TValue : IAsyncDisposable
35+
{
36+
_ = cacheEntityReference.CacheEntity.WithExpirationCallback(expirationCallback);
37+
return cacheEntityReference;
38+
}
3139
}

0 commit comments

Comments
 (0)