Skip to content

Commit 0d63074

Browse files
author
Travis Whidden
committed
zzzprojects#769 - Enhancements to SlidingCache<> for Improved Thread Safety and Performance
1 parent db0824e commit 0d63074

File tree

3 files changed

+85
-23
lines changed

3 files changed

+85
-23
lines changed

src/System.Linq.Dynamic.Core/Util/Cache/CacheConfig.cs

+9
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,13 @@ public class CacheConfig
2424
/// By default, cleanup occurs every 10 minutes.
2525
/// </summary>
2626
public TimeSpan CleanupFrequency { get; set; } = SlidingCacheConstants.DefaultCleanupFrequency;
27+
28+
/// <summary>
29+
/// Enables returning expired cache items in scenarios where cleanup, running on a separate thread,
30+
/// has not yet removed them. This allows for the retrieval of an expired object without needing to
31+
/// clear and recreate it if a request is made concurrently with cleanup. Particularly useful
32+
/// when cached items are deterministic, ensuring consistent results even from expired entries.
33+
/// Default true;
34+
/// </summary>
35+
public bool PermitExpiredReturns { get; set; } = true;
2736
}

src/System.Linq.Dynamic.Core/Util/Cache/SlidingCache.cs

+55-23
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Concurrent;
22
using System.Diagnostics.CodeAnalysis;
33
using System.Linq.Dynamic.Core.Validation;
4+
using System.Threading;
45

56
namespace System.Linq.Dynamic.Core.Util.Cache;
67

@@ -11,7 +12,9 @@ internal class SlidingCache<TKey, TValue> where TKey : notnull where TValue : no
1112
private readonly IDateTimeUtils _dateTimeProvider;
1213
private readonly Action _deleteExpiredCachedItemsDelegate;
1314
private readonly long? _minCacheItemsBeforeCleanup;
15+
private readonly bool _permitExpiredReturns;
1416
private DateTime _lastCleanupTime;
17+
private int _cleanupLocked = 0;
1518

1619
/// <summary>
1720
/// Sliding Thread Safe Cache
@@ -26,15 +29,19 @@ internal class SlidingCache<TKey, TValue> where TKey : notnull where TValue : no
2629
/// Provides the Time for the Caching object. Default will be created if not supplied. Used
2730
/// for Testing classes
2831
/// </param>
32+
/// <param name="permitExpiredReturns">If a request for an item happens to be expired, but is still
33+
/// in known, don't expire it and return it instead.</param>
2934
public SlidingCache(
3035
TimeSpan timeToLive,
3136
TimeSpan? cleanupFrequency = null,
3237
long? minCacheItemsBeforeCleanup = null,
33-
IDateTimeUtils? dateTimeProvider = null)
38+
IDateTimeUtils? dateTimeProvider = null,
39+
bool permitExpiredReturns = false)
3440
{
3541
_cache = new ConcurrentDictionary<TKey, CacheEntry<TValue>>();
3642
TimeToLive = timeToLive;
3743
_minCacheItemsBeforeCleanup = minCacheItemsBeforeCleanup;
44+
_permitExpiredReturns = permitExpiredReturns;
3845
_cleanupFrequency = cleanupFrequency ?? SlidingCacheConstants.DefaultCleanupFrequency;
3946
_deleteExpiredCachedItemsDelegate = Cleanup;
4047
_dateTimeProvider = dateTimeProvider ?? new DateTimeUtils();
@@ -58,6 +65,7 @@ public SlidingCache(CacheConfig cacheConfig, IDateTimeUtils? dateTimeProvider =
5865
TimeToLive = cacheConfig.TimeToLive;
5966
_minCacheItemsBeforeCleanup = cacheConfig.MinItemsTrigger;
6067
_cleanupFrequency = cacheConfig.CleanupFrequency;
68+
_permitExpiredReturns = cacheConfig.PermitExpiredReturns;
6169
_deleteExpiredCachedItemsDelegate = Cleanup;
6270
_dateTimeProvider = dateTimeProvider ?? new DateTimeUtils();
6371
// To prevent a scan on first call, set the last Cleanup to the current Provider time
@@ -100,20 +108,29 @@ public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue? value)
100108
{
101109
Check.NotNull(key);
102110

103-
CleanupIfNeeded();
104-
105-
if (_cache.TryGetValue(key, out var valueAndExpiration))
111+
try
106112
{
107-
if (_dateTimeProvider.UtcNow <= valueAndExpiration.ExpirationTime)
113+
if (_cache.TryGetValue(key, out var valueAndExpiration))
108114
{
109-
value = valueAndExpiration.Value;
110-
var newExpire = _dateTimeProvider.UtcNow.Add(TimeToLive);
111-
_cache[key] = new CacheEntry<TValue>(value, newExpire);
112-
return true;
115+
// Permit expired returns will return the object even if was expired
116+
// this will prevent the need to re-create the object for the caller
117+
if (_permitExpiredReturns || _dateTimeProvider.UtcNow <= valueAndExpiration.ExpirationTime)
118+
{
119+
value = valueAndExpiration.Value;
120+
var newExpire = _dateTimeProvider.UtcNow.Add(TimeToLive);
121+
_cache[key] = new CacheEntry<TValue>(value, newExpire);
122+
return true;
123+
}
124+
125+
// Remove expired item
126+
_cache.TryRemove(key, out _);
113127
}
114-
115-
// Remove expired item
116-
_cache.TryRemove(key, out _);
128+
}
129+
finally
130+
{
131+
// If permit expired returns are enabled,
132+
// we want to ensure the cache has a chance to get the value
133+
CleanupIfNeeded();
117134
}
118135

119136
value = default;
@@ -131,26 +148,41 @@ public bool Remove(TKey key)
131148

132149
private void CleanupIfNeeded()
133150
{
134-
if (_dateTimeProvider.UtcNow - _lastCleanupTime > _cleanupFrequency
135-
&& (_minCacheItemsBeforeCleanup == null ||
136-
_cache.Count >=
137-
_minCacheItemsBeforeCleanup) // Only cleanup if we have a minimum number of items in the cache.
138-
)
151+
// Ensure this is only executing one at a time.
152+
if (Interlocked.CompareExchange(ref _cleanupLocked, 1, 0) != 0)
153+
{
154+
return;
155+
}
156+
157+
try
139158
{
140-
// Set here, so we don't have re-entry due to large collection enumeration.
141-
_lastCleanupTime = _dateTimeProvider.UtcNow;
159+
if (_dateTimeProvider.UtcNow - _lastCleanupTime > _cleanupFrequency
160+
&& (_minCacheItemsBeforeCleanup == null ||
161+
_cache.Count >=
162+
_minCacheItemsBeforeCleanup) // Only cleanup if we have a minimum number of items in the cache.
163+
)
164+
{
165+
// Set here, so we don't have re-entry due to large collection enumeration.
166+
_lastCleanupTime = _dateTimeProvider.UtcNow;
142167

143-
TaskUtils.Run(_deleteExpiredCachedItemsDelegate);
168+
TaskUtils.Run(_deleteExpiredCachedItemsDelegate);
169+
}
170+
}
171+
finally
172+
{
173+
// Release the lock
174+
_cleanupLocked = 0;
144175
}
145176
}
146177

147178
private void Cleanup()
148179
{
149-
foreach (var key in _cache.Keys)
180+
// Enumerate the key/value - safe per docs
181+
foreach (var keyValue in _cache)
150182
{
151-
if (_dateTimeProvider.UtcNow > _cache[key].ExpirationTime)
183+
if (_dateTimeProvider.UtcNow > keyValue.Value.ExpirationTime)
152184
{
153-
_cache.TryRemove(key, out _);
185+
_cache.TryRemove(keyValue.Key, out _);
154186
}
155187
}
156188
}

test/System.Linq.Dynamic.Core.Tests/Util/Cache/SlidingCacheTests.cs

+21
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ public void SlidingCache_TestExpire()
6363
}
6464
}
6565

66+
[Fact]
67+
public void SlidingCache_TestExpiredReturn()
68+
{
69+
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
70+
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
71+
72+
var cache = new SlidingCache<int, string>(TimeSpan.FromMinutes(10),
73+
dateTimeProvider: dateTimeUtilsMock.Object, permitExpiredReturns: true);
74+
75+
// Act
76+
cache.AddOrUpdate(1, "one");
77+
78+
var newDateTime = dateTimeUtilsMock.Object.UtcNow.AddMinutes(11);
79+
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(newDateTime);
80+
81+
if (!cache.TryGetValue(1, out var value))
82+
{
83+
Assert.True(false, $"Expected to not find the value, but found {value}");
84+
}
85+
}
86+
6687
[Fact]
6788
public void SlidingCache_TestAutoExpire()
6889
{

0 commit comments

Comments
 (0)