Skip to content

Commit d974a9a

Browse files
committed
Implement ExpirationStrategy with possibility of creating custom ones
1 parent 20be76e commit d974a9a

12 files changed

Lines changed: 252 additions & 41 deletions

AsyncMemoryCache.Tests/AsyncMemoryCacheTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using AsyncMemoryCache.EvictionBehaviors;
2+
using AsyncMemoryCache.ExpirationStrategy;
23
using Microsoft.Extensions.Logging;
34
using Nito.AsyncEx;
45
using NSubstitute;
@@ -267,6 +268,58 @@ public void TryGetValue_ReturnsTrueIfItemExistsAndIncrementsReferenceCount()
267268
Assert.Equal(1, cacheEntityReference.CacheEntity.References);
268269
}
269270

271+
[Fact]
272+
public void TryGetValue_ExistingItemWithExpirationStrategy_CallsCacheEntityAccessed()
273+
{
274+
const string key = "test";
275+
276+
var expirationStrategy = Substitute.For<IExpirationStrategy>();
277+
278+
var config = new AsyncMemoryCacheConfiguration<string, IAsyncDisposable>
279+
{
280+
CacheBackingStore = new Dictionary<string, CacheEntity<string, IAsyncDisposable>>()
281+
{
282+
{
283+
key,
284+
new CacheEntity<string, IAsyncDisposable>(key, () => Task.FromResult(Substitute.For<IAsyncDisposable>()), AsyncLazyFlags.None)
285+
.WithExpirationStrategy(expirationStrategy)
286+
}
287+
}
288+
};
289+
290+
var target = new AsyncMemoryCache<string, IAsyncDisposable>(config);
291+
292+
_ = target.TryGetValue(key, out var cacheEntityReference);
293+
294+
expirationStrategy.Received(1).CacheEntityAccessed();
295+
}
296+
297+
[Fact]
298+
public void Indexer_ExistingItemWithExpirationStrategy_CallsCacheEntityAccessed()
299+
{
300+
const string key = "test";
301+
302+
var expirationStrategy = Substitute.For<IExpirationStrategy>();
303+
304+
var config = new AsyncMemoryCacheConfiguration<string, IAsyncDisposable>
305+
{
306+
CacheBackingStore = new Dictionary<string, CacheEntity<string, IAsyncDisposable>>()
307+
{
308+
{
309+
key,
310+
new CacheEntity<string, IAsyncDisposable>(key, () => Task.FromResult(Substitute.For<IAsyncDisposable>()), AsyncLazyFlags.None)
311+
.WithExpirationStrategy(expirationStrategy)
312+
}
313+
}
314+
};
315+
316+
var target = new AsyncMemoryCache<string, IAsyncDisposable>(config);
317+
318+
_ = target[key];
319+
320+
expirationStrategy.Received(1).CacheEntityAccessed();
321+
}
322+
270323
private static AsyncMemoryCacheConfiguration<string, IAsyncDisposable> CreateConfiguration()
271324
{
272325
return new()

AsyncMemoryCache.Tests/CacheEntityReferenceExtensionTests.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
using AsyncMemoryCache.Extensions;
1+
using AsyncMemoryCache.ExpirationStrategy;
2+
using AsyncMemoryCache.Extensions;
23
using Nito.AsyncEx;
4+
using NSubstitute;
35
using System;
46
using System.Threading.Tasks;
57
using Xunit;
@@ -17,7 +19,9 @@ public void WithAbsoluteExpirationExtension()
1719

1820
cacheEntityReference.WithAbsoluteExpiration(expectedAbsoluteExpiration);
1921

20-
Assert.Equal(expectedAbsoluteExpiration, cacheEntity.AbsoluteExpiration);
22+
var absoluteExpirationStrategy = cacheEntity.ExpirationStrategy as AbsoluteExpirationStrategy;
23+
Assert.NotNull(absoluteExpirationStrategy);
24+
Assert.Equal(expectedAbsoluteExpiration, absoluteExpirationStrategy.AbsoluteExpiration);
2125
}
2226

2327
[Fact]
@@ -29,6 +33,21 @@ public void WithSlidingExpirationExtension()
2933

3034
cacheEntityReference.WithSlidingExpiration(expectedSlidingExpirationWindow);
3135

32-
Assert.Equal(expectedSlidingExpirationWindow, cacheEntity.SlidingExpiration);
36+
var slidingExpirationStrategy = cacheEntity.ExpirationStrategy as SlidingExpirationStrategy;
37+
Assert.NotNull(slidingExpirationStrategy);
38+
Assert.Equal(expectedSlidingExpirationWindow, slidingExpirationStrategy.SlidingExpirationWindow);
39+
}
40+
41+
[Fact]
42+
public void WithExpirationStrategyExtension()
43+
{
44+
var expirationStrategy = Substitute.For<IExpirationStrategy>();
45+
46+
var cacheEntity = new CacheEntity<string, IAsyncDisposable>("test", () => Task.FromResult((IAsyncDisposable)null!), AsyncLazyFlags.None);
47+
var cacheEntityReference = new CacheEntityReference<string, IAsyncDisposable>(cacheEntity);
48+
49+
cacheEntityReference.WithExpirationStrategy(expirationStrategy);
50+
51+
Assert.Equal(expirationStrategy, cacheEntity.ExpirationStrategy);
3352
}
3453
}

AsyncMemoryCache.Tests/CacheEntityTests.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Nito.AsyncEx;
1+
using AsyncMemoryCache.ExpirationStrategy;
2+
using Nito.AsyncEx;
23
using NSubstitute;
34
using System;
45
using System.Threading.Tasks;
@@ -17,7 +18,10 @@ public void WithAbsoluteExpiration()
1718
var cacheEntityReturned = cacheEntity.WithAbsoluteExpiration(absoluteExpiration);
1819

1920
Assert.Same(cacheEntity, cacheEntityReturned);
20-
Assert.Equal(absoluteExpiration, cacheEntityReturned.AbsoluteExpiration);
21+
22+
var absoluteExpirationStrategy = cacheEntityReturned.ExpirationStrategy as AbsoluteExpirationStrategy;
23+
Assert.NotNull(absoluteExpirationStrategy);
24+
Assert.Equal(absoluteExpiration, absoluteExpirationStrategy.AbsoluteExpiration);
2125
}
2226

2327
[Fact]
@@ -29,6 +33,16 @@ public void WithSlidingExpiration()
2933
var cacheEntityReturned = cacheEntity.WithSlidingExpiration(slidingExpiration);
3034

3135
Assert.Same(cacheEntity, cacheEntityReturned);
32-
Assert.Equal(slidingExpiration, cacheEntityReturned.SlidingExpiration);
36+
37+
var slidingExpirationStrategy = cacheEntityReturned.ExpirationStrategy as SlidingExpirationStrategy;
38+
Assert.NotNull(slidingExpirationStrategy);
39+
Assert.Equal(slidingExpiration, slidingExpirationStrategy.SlidingExpirationWindow);
40+
}
41+
42+
[Fact]
43+
public void WithoutAnyExpirationStrategy()
44+
{
45+
var cacheEntity = new CacheEntity<string, IAsyncDisposable>("key", () => Task.FromResult(Substitute.For<IAsyncDisposable>()), AsyncLazyFlags.None);
46+
Assert.Null(cacheEntity.ExpirationStrategy);
3347
}
3448
}

AsyncMemoryCache.Tests/EvictionBehaviorTests.cs

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,11 @@ public async Task DefaultEvictionBehavior_RemovesAbsoluteExpiredItem_LeavesNonEx
7070
{
7171
{
7272
notExpiredKey, new CacheEntity<string, IAsyncDisposable>(notExpiredKey, () => Task.FromResult(notExpiredCacheObject), AsyncLazyFlags.None)
73-
{
74-
AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(2)
75-
}
73+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow.AddDays(2))
7674
},
7775
{
7876
expiredKey, new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
79-
{
80-
AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(-1)
81-
}
77+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow.AddMinutes(-1))
8278
}
8379
};
8480

@@ -98,15 +94,11 @@ public async Task DefaultEvictionBehavior_RemovesSlidingExpiredItem_LeavesNonExp
9894
{
9995
{
10096
notExpiredKey, new CacheEntity<string, IAsyncDisposable>(notExpiredKey, () => Task.FromResult(notExpiredCacheObject), AsyncLazyFlags.None)
101-
{
102-
SlidingExpiration = TimeSpan.FromDays(2)
103-
}
97+
.WithSlidingExpiration(TimeSpan.FromDays(2))
10498
},
10599
{
106100
expiredKey, new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
107-
{
108-
SlidingExpiration = TimeSpan.FromTicks(1)
109-
}
101+
.WithSlidingExpiration(TimeSpan.FromTicks(1))
110102
}
111103
};
112104

@@ -198,9 +190,7 @@ public async Task ExpiredCacheItem_AlwaysDisposedIfRefCountBelowZero()
198190
.Returns(
199191
[
200192
new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
201-
{
202-
AbsoluteExpiration = DateTimeOffset.UtcNow
203-
}
193+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow)
204194
])
205195
.AndDoes(_ => resetEvent.Set());
206196

@@ -233,9 +223,7 @@ public async Task ExpiredCacheItem_NeverDisposedIfRefCountAboveZero()
233223
{
234224
{
235225
expiredKey, new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
236-
{
237-
AbsoluteExpiration = DateTimeOffset.UtcNow
238-
}
226+
.WithAbsoluteExpiration(DateTimeOffset.UtcNow)
239227
}
240228
};
241229

@@ -258,4 +246,35 @@ public async Task ExpiredCacheItem_NeverDisposedIfRefCountAboveZero()
258246

259247
await expiredCacheObject.DidNotReceive().DisposeAsync();
260248
}
249+
250+
[Fact]
251+
public async Task CacheItemWithoutExpirationStrategy_NeverDisposed()
252+
{
253+
var expiredCacheObject = Substitute.For<IAsyncDisposable>();
254+
const string expiredKey = "expired";
255+
var cacheBackingStore = new Dictionary<string, CacheEntity<string, IAsyncDisposable>>
256+
{
257+
{
258+
// Not setting any expiration strategy
259+
expiredKey, new CacheEntity<string, IAsyncDisposable>(expiredKey, () => Task.FromResult(expiredCacheObject), AsyncLazyFlags.None)
260+
}
261+
};
262+
263+
var resetEvent = new ManualResetEvent(false);
264+
var config = Substitute.For<IAsyncMemoryCacheConfiguration<string, IAsyncDisposable>>();
265+
_ = config.CacheBackingStore
266+
.Returns(cacheBackingStore)
267+
.AndDoes(_ => resetEvent.Set());
268+
269+
var timeProvider = new FakeTimeProvider(DateTime.UtcNow);
270+
var target = new DefaultEvictionBehavior(timeProvider);
271+
target.Start(config, _logger);
272+
273+
timeProvider.Advance(TimeSpan.FromDays(30));
274+
275+
_ = resetEvent.WaitOne();
276+
await target.DisposeAsync();
277+
278+
await expiredCacheObject.DidNotReceive().DisposeAsync();
279+
}
261280
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using AsyncMemoryCache.ExpirationStrategy;
2+
using System;
3+
using Xunit;
4+
5+
namespace AsyncMemoryCache.Tests;
6+
7+
public class ExpirationStrategyTests
8+
{
9+
[Theory]
10+
[InlineData(-1, true)]
11+
[InlineData(1, false)]
12+
public void AbsoluteExpirationStrategy(int minuteOffset, bool expectedExpiryState)
13+
{
14+
var target = new AbsoluteExpirationStrategy(DateTimeOffset.UtcNow.AddMinutes(minuteOffset));
15+
Assert.Equal(expectedExpiryState, target.IsExpired());
16+
}
17+
18+
[Theory]
19+
[InlineData(-2, true)]
20+
[InlineData(0, false)]
21+
public void SlidingExpirationStrategy(int lastUseOffset, bool expectedExpiryState)
22+
{
23+
var target = new SlidingExpirationStrategy(TimeSpan.FromMinutes(1))
24+
{
25+
LastUse = DateTimeOffset.UtcNow.AddMinutes(lastUseOffset)
26+
};
27+
28+
Assert.Equal(expectedExpiryState, target.IsExpired());
29+
}
30+
31+
[Fact]
32+
public void SlidingExpirationStrategy_CacheEntityAccessed_SetsLastUse()
33+
{
34+
var lastUse = DateTimeOffset.MinValue;
35+
var target = new SlidingExpirationStrategy(TimeSpan.Zero)
36+
{
37+
LastUse = lastUse
38+
};
39+
40+
target.CacheEntityAccessed();
41+
42+
Assert.NotEqual(lastUse, target.LastUse);
43+
}
44+
}

AsyncMemoryCache/AsyncMemoryCache.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public CacheEntityReference<TKey, TValue> this[TKey key]
4444
get
4545
{
4646
var cacheEntity = _cache[key];
47-
cacheEntity.LastUse = DateTimeOffset.UtcNow;
47+
cacheEntity.ExpirationStrategy?.CacheEntityAccessed();
4848
return new(cacheEntity);
4949
}
5050
}
@@ -75,7 +75,7 @@ public bool TryGetValue(TKey key, [NotNullWhen(true)] out CacheEntityReference<T
7575
// so it is in fact expired, as such we shouldn't use it
7676
if (Interlocked.Increment(ref entity.References) > 0)
7777
{
78-
entity.LastUse = DateTimeOffset.UtcNow;
78+
entity.ExpirationStrategy?.CacheEntityAccessed();
7979

8080
var cacheEntityReference = new CacheEntityReference<TKey, TValue>(entity);
8181

AsyncMemoryCache/CacheEntity.cs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Nito.AsyncEx;
1+
using AsyncMemoryCache.ExpirationStrategy;
2+
using Nito.AsyncEx;
23
using System;
34
using System.Threading.Tasks;
45

@@ -15,24 +16,28 @@ public CacheEntity(TKey key, Func<Task<TValue>> objectFactory, AsyncLazyFlags la
1516
}
1617

1718
public TKey Key { get; }
18-
public DateTimeOffset? AbsoluteExpiration { get; set; }
19-
public TimeSpan? SlidingExpiration { get; set; }
20-
public AsyncLazy<TValue> ObjectFactory { get; }
2119

22-
internal DateTimeOffset LastUse { get; set; } = DateTimeOffset.UtcNow;
20+
public AsyncLazy<TValue> ObjectFactory { get; }
2321

2422
private int _references;
2523
internal ref int References => ref _references;
24+
internal IExpirationStrategy? ExpirationStrategy { get; private set; }
2625

2726
public CacheEntity<TKey, TValue> WithAbsoluteExpiration(DateTimeOffset expiryDate)
2827
{
29-
AbsoluteExpiration = expiryDate;
28+
ExpirationStrategy = new AbsoluteExpirationStrategy(expiryDate);
3029
return this;
3130
}
3231

3332
public CacheEntity<TKey, TValue> WithSlidingExpiration(TimeSpan slidingExpirationWindow)
3433
{
35-
SlidingExpiration = slidingExpirationWindow;
34+
ExpirationStrategy = new SlidingExpirationStrategy(slidingExpirationWindow);
35+
return this;
36+
}
37+
38+
public CacheEntity<TKey, TValue> WithExpirationStrategy(IExpirationStrategy expirationStrategy)
39+
{
40+
ExpirationStrategy = expirationStrategy;
3641
return this;
3742
}
3843
}

AsyncMemoryCache/EvictionBehaviors/DefaultEvictionBehavior.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,10 @@ private static async Task CheckExpiredItems<TKey, TValue>(IAsyncMemoryCacheConfi
6161
continue;
6262
}
6363

64-
if (DateTimeOffset.UtcNow > item.AbsoluteExpiration
65-
|| DateTimeOffset.UtcNow - item.LastUse > item.SlidingExpiration)
66-
{
67-
expiredItems.Add(item);
68-
}
64+
if (!(item.ExpirationStrategy?.IsExpired() ?? false))
65+
continue;
66+
67+
expiredItems.Add(item);
6968
}
7069

7170
foreach (var expiredItem in expiredItems)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
4+
namespace AsyncMemoryCache.ExpirationStrategy;
5+
6+
internal sealed class AbsoluteExpirationStrategy : IExpirationStrategy
7+
{
8+
internal DateTimeOffset AbsoluteExpiration { get; }
9+
10+
public AbsoluteExpirationStrategy(DateTimeOffset expiryDate)
11+
{
12+
AbsoluteExpiration = expiryDate;
13+
}
14+
15+
public bool IsExpired()
16+
=> DateTimeOffset.UtcNow > AbsoluteExpiration;
17+
18+
[ExcludeFromCodeCoverage(Justification = "Empty implementation")]
19+
public void CacheEntityAccessed() { }
20+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace AsyncMemoryCache.ExpirationStrategy;
2+
3+
public interface IExpirationStrategy
4+
{
5+
bool IsExpired();
6+
void CacheEntityAccessed();
7+
}

0 commit comments

Comments
 (0)