Skip to content

Commit ad3cfb4

Browse files
committed
Added "itemEvicted" callback, fixes #6 fixes #11
1 parent 262a663 commit ad3cfb4

File tree

2 files changed

+117
-3
lines changed

2 files changed

+117
-3
lines changed

FastCache/FastCache.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,22 @@ public class FastCache<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>,
1515
private readonly ConcurrentDictionary<TKey, TtlValue> _dict = new ConcurrentDictionary<TKey, TtlValue>();
1616

1717
private readonly Timer _cleanUpTimer;
18+
private readonly EvictionCallback _itemEvicted;
19+
20+
/// <summary>
21+
/// Callback (RUNS ON THREAD POOL!) when an item is evicted from the cache.
22+
/// </summary>
23+
/// <param name="key"></param>
24+
public delegate void EvictionCallback(TKey key);
1825

1926
/// <summary>
2027
/// Initializes a new empty instance of <see cref="FastCache{TKey,TValue}"/>
2128
/// </summary>
2229
/// <param name="cleanupJobInterval">cleanup interval in milliseconds, default is 10000</param>
23-
public FastCache(int cleanupJobInterval = 10000)
30+
/// <param name="itemEvicted">Optional callback (RUNS ON THREAD POOL!) when an item is evicted from the cache</param>
31+
public FastCache(int cleanupJobInterval = 10000, EvictionCallback itemEvicted = null)
2432
{
33+
_itemEvicted = itemEvicted;
2534
_cleanUpTimer = new Timer(s => { _ = EvictExpiredJob(); }, null, cleanupJobInterval, cleanupJobInterval);
2635
}
2736

@@ -65,7 +74,10 @@ public void EvictExpired()
6574
foreach (var p in _dict)
6675
{
6776
if (p.Value.IsExpired(currTime)) //call IsExpired with "currTime" to avoid calling Environment.TickCount64 multiple times
77+
{
6878
_dict.TryRemove(p);
79+
OnEviction(p.Key);
80+
}
6981
}
7082
}
7183
finally
@@ -151,6 +163,8 @@ public bool TryGet(TKey key, out TValue value)
151163
*
152164
* */
153165

166+
OnEviction(key);
167+
154168
return false;
155169
}
156170

@@ -188,7 +202,8 @@ private TValue GetOrAddCore(TKey key, Func<TValue> valueFactory, TimeSpan ttl)
188202
//since TtlValue is a reference type we can update its properties in-place, instead of removing and re-adding to the dictionary (extra lookups)
189203
if (!wasAdded) //performance hack: skip expiration check if a brand item was just added
190204
{
191-
ttlValue.ModifyIfExpired(valueFactory, ttl);
205+
if (ttlValue.ModifyIfExpired(valueFactory, ttl))
206+
OnEviction(key);
192207
}
193208

194209
return ttlValue.Value;
@@ -259,6 +274,22 @@ IEnumerator IEnumerable.GetEnumerator()
259274
return this.GetEnumerator();
260275
}
261276

277+
private void OnEviction(TKey key)
278+
{
279+
if (_itemEvicted == null) return;
280+
281+
Task.Run(() => //run on thread pool to avoid blocking
282+
{
283+
try
284+
{
285+
_itemEvicted(key);
286+
}
287+
catch {
288+
var i = 0;
289+
} //to prevent any exceptions from crashing the thread
290+
});
291+
}
292+
262293
private class TtlValue
263294
{
264295
public TValue Value { get; private set; }
@@ -278,14 +309,17 @@ public TtlValue(TValue value, TimeSpan ttl)
278309
/// <summary>
279310
/// Updates the value and TTL only if the item is expired
280311
/// </summary>
281-
public void ModifyIfExpired(Func<TValue> newValueFactory, TimeSpan newTtl)
312+
/// <returns>True if the item expired and was updated, otherwise false</returns>
313+
public bool ModifyIfExpired(Func<TValue> newValueFactory, TimeSpan newTtl)
282314
{
283315
var ticks = Environment.TickCount64; //save to a var to prevent multiple calls to Environment.TickCount64
284316
if (IsExpired(ticks)) //if expired - update the value and TTL
285317
{
286318
TickCountWhenToKill = ticks + (long)newTtl.TotalMilliseconds; //update the expiration time first for better concurrency
287319
Value = newValueFactory();
320+
return true;
288321
}
322+
return false;
289323
}
290324
}
291325

UnitTests/EvictionCallbackTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System;
2+
using Jitbit.Utils;
3+
4+
namespace UnitTests;
5+
6+
[TestClass]
7+
public class EvictionCallbackTests
8+
{
9+
private List<string> _evictedKeys;
10+
private FastCache<string, string> _cache;
11+
12+
[TestInitialize]
13+
public void Setup()
14+
{
15+
_evictedKeys = new List<string>();
16+
_cache = new FastCache<string, string>(
17+
cleanupJobInterval: 100,
18+
itemEvicted: key => _evictedKeys.Add(key));
19+
}
20+
21+
[TestMethod]
22+
public async Task WhenItemExpires_EvictionCallbackFires()
23+
{
24+
// Arrange
25+
var key = "test-key";
26+
_cache.AddOrUpdate(key, "value", TimeSpan.FromMilliseconds(1));
27+
28+
// Act
29+
await Task.Delay(110); // Wait for expiration
30+
31+
// Assert
32+
Assert.AreEqual(1, _evictedKeys.Count);
33+
Assert.AreEqual(key, _evictedKeys[0]);
34+
}
35+
36+
[TestMethod]
37+
public async Task WhenMultipleItemsExpire_CallbackFiresForEach()
38+
{
39+
// Arrange
40+
var keys = new[] { "key1", "key2", "key3" };
41+
foreach (var key in keys)
42+
{
43+
_cache.AddOrUpdate(key, "value", TimeSpan.FromMilliseconds(1));
44+
}
45+
46+
// Act
47+
await Task.Delay(5); // Wait for 1ms expiration
48+
_cache.EvictExpired();
49+
await Task.Delay(5); // Wait for callback to finish on another thread
50+
51+
// Assert
52+
CollectionAssert.AreEquivalent(keys, _evictedKeys);
53+
}
54+
55+
[TestMethod]
56+
public void WhenItemNotExpired_CallbackDoesNotFire()
57+
{
58+
// Arrange
59+
_cache.AddOrUpdate("key", "value", TimeSpan.FromMinutes(1));
60+
61+
// Act
62+
_cache.EvictExpired();
63+
64+
// Assert
65+
Assert.AreEqual(0, _evictedKeys.Count);
66+
}
67+
68+
[TestMethod]
69+
public async Task AutomaticCleanup_FiresCallback()
70+
{
71+
// Arrange
72+
_cache.AddOrUpdate("key", "value", TimeSpan.FromMilliseconds(1));
73+
74+
// Act
75+
await Task.Delay(110); // Wait for cleanup job
76+
77+
// Assert
78+
Assert.AreEqual(1, _evictedKeys.Count);
79+
}
80+
}

0 commit comments

Comments
 (0)