Skip to content

Commit 7e3b8ed

Browse files
TWhiddenTravis Whidden
and
Travis Whidden
authored
Enhancements to SlidingCache<> for Improved Thread Safety and Performance (#770)
* #769 - Enhancements to SlidingCache<> for Improved Thread Safety and Performance * #769 - Rename Config ReturnExpiredItems; Refactor Tests * #769 - SlidingCache Test Code-Comment Cleanup --------- Co-authored-by: Travis Whidden <[email protected]>
1 parent db0824e commit 7e3b8ed

File tree

3 files changed

+94
-27
lines changed

3 files changed

+94
-27
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 ReturnExpiredItems { 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 _returnExpiredItems;
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="returnExpiredItems">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 returnExpiredItems = false)
3440
{
3541
_cache = new ConcurrentDictionary<TKey, CacheEntry<TValue>>();
3642
TimeToLive = timeToLive;
3743
_minCacheItemsBeforeCleanup = minCacheItemsBeforeCleanup;
44+
_returnExpiredItems = returnExpiredItems;
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+
_returnExpiredItems = cacheConfig.ReturnExpiredItems;
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 (_returnExpiredItems || _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

+30-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public class SlidingCacheTests
1515
public void SlidingCache_CacheOperations()
1616
{
1717
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
18+
// Configure Mock with SetupGet since SlidingCache can be non-deterministic; don't use SetupSequence
1819
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
1920

2021
// Arrange
@@ -46,27 +47,51 @@ public void SlidingCache_CacheOperations()
4647
public void SlidingCache_TestExpire()
4748
{
4849
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
50+
// Configure Mock with SetupGet since SlidingCache can be non-deterministic; don't use SetupSequence
4951
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
5052

53+
// Arrange
5154
var cache = new SlidingCache<int, string>(TimeSpan.FromMinutes(10),
5255
dateTimeProvider: dateTimeUtilsMock.Object);
5356

5457
// Act
5558
cache.AddOrUpdate(1, "one");
5659

60+
// move the time forward
61+
var newDateTime = dateTimeUtilsMock.Object.UtcNow.AddMinutes(11);
62+
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(newDateTime);
63+
64+
// Ensure that the element has expired
65+
cache.TryGetValue(1, out var value).Should().BeFalse($"Expected to not find the value, but found {value}");
66+
}
67+
68+
[Fact]
69+
public void SlidingCache_TestReturnExpiredItems()
70+
{
71+
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
72+
// Configure Mock with SetupGet since SlidingCache can be non-deterministic; don't use SetupSequence
73+
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
74+
75+
// Arrange
76+
var cache = new SlidingCache<int, string>(TimeSpan.FromMinutes(10),
77+
dateTimeProvider: dateTimeUtilsMock.Object, returnExpiredItems: true);
78+
79+
// Act
80+
cache.AddOrUpdate(1, "one");
81+
82+
// move the time forward
5783
var newDateTime = dateTimeUtilsMock.Object.UtcNow.AddMinutes(11);
5884
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(newDateTime);
5985

60-
if (cache.TryGetValue(1, out var value))
61-
{
62-
Assert.True(false, $"Expected to not find the value, but found {value}");
63-
}
86+
// Ensure the expired item is returned from the cache
87+
cache.TryGetValue(1, out _).Should().BeTrue($"Expected to return expired item");
6488
}
6589

6690
[Fact]
6791
public void SlidingCache_TestAutoExpire()
6892
{
6993
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
94+
// Configure Mock with SetupGet since SlidingCache can be non-deterministic; don't use SetupSequence
7095
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
7196

7297
// Arrange
@@ -109,6 +134,7 @@ public void SlidingCache_TestNull()
109134
public void SlidingCache_TestMinNumberBeforeTests()
110135
{
111136
var dateTimeUtilsMock = new Mock<IDateTimeUtils>();
137+
// Configure Mock with SetupGet since SlidingCache can be non-deterministic; don't use SetupSequence
112138
dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow);
113139

114140
// Arrange

0 commit comments

Comments
 (0)