Skip to content

Commit

Permalink
Added "itemEvicted" callback, fixes #6 fixes #11
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-jitbit committed Nov 5, 2024
1 parent 262a663 commit ad3cfb4
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 3 deletions.
40 changes: 37 additions & 3 deletions FastCache/FastCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,22 @@ public class FastCache<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>,
private readonly ConcurrentDictionary<TKey, TtlValue> _dict = new ConcurrentDictionary<TKey, TtlValue>();

private readonly Timer _cleanUpTimer;
private readonly EvictionCallback _itemEvicted;

/// <summary>
/// Callback (RUNS ON THREAD POOL!) when an item is evicted from the cache.
/// </summary>
/// <param name="key"></param>
public delegate void EvictionCallback(TKey key);

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

Expand Down Expand Up @@ -65,7 +74,10 @@ public void EvictExpired()
foreach (var p in _dict)
{
if (p.Value.IsExpired(currTime)) //call IsExpired with "currTime" to avoid calling Environment.TickCount64 multiple times
{
_dict.TryRemove(p);
OnEviction(p.Key);
}
}
}
finally
Expand Down Expand Up @@ -151,6 +163,8 @@ public bool TryGet(TKey key, out TValue value)
*
* */

OnEviction(key);

return false;
}

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

return ttlValue.Value;
Expand Down Expand Up @@ -259,6 +274,22 @@ IEnumerator IEnumerable.GetEnumerator()
return this.GetEnumerator();
}

private void OnEviction(TKey key)
{
if (_itemEvicted == null) return;

Task.Run(() => //run on thread pool to avoid blocking
{
try
{
_itemEvicted(key);
}
catch {
var i = 0;

Check warning on line 288 in FastCache/FastCache.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'i' is assigned but its value is never used

Check warning on line 288 in FastCache/FastCache.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'i' is assigned but its value is never used

Check warning on line 288 in FastCache/FastCache.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'i' is assigned but its value is never used

Check warning on line 288 in FastCache/FastCache.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'i' is assigned but its value is never used
} //to prevent any exceptions from crashing the thread
});
}

private class TtlValue
{
public TValue Value { get; private set; }
Expand All @@ -278,14 +309,17 @@ public TtlValue(TValue value, TimeSpan ttl)
/// <summary>
/// Updates the value and TTL only if the item is expired
/// </summary>
public void ModifyIfExpired(Func<TValue> newValueFactory, TimeSpan newTtl)
/// <returns>True if the item expired and was updated, otherwise false</returns>
public bool ModifyIfExpired(Func<TValue> newValueFactory, TimeSpan newTtl)
{
var ticks = Environment.TickCount64; //save to a var to prevent multiple calls to Environment.TickCount64
if (IsExpired(ticks)) //if expired - update the value and TTL
{
TickCountWhenToKill = ticks + (long)newTtl.TotalMilliseconds; //update the expiration time first for better concurrency
Value = newValueFactory();
return true;
}
return false;
}
}

Expand Down
80 changes: 80 additions & 0 deletions UnitTests/EvictionCallbackTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using Jitbit.Utils;

namespace UnitTests;

[TestClass]
public class EvictionCallbackTests
{
private List<string> _evictedKeys;

Check warning on line 9 in UnitTests/EvictionCallbackTests.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_evictedKeys' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 9 in UnitTests/EvictionCallbackTests.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_evictedKeys' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
private FastCache<string, string> _cache;

Check warning on line 10 in UnitTests/EvictionCallbackTests.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_cache' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 10 in UnitTests/EvictionCallbackTests.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_cache' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

[TestInitialize]
public void Setup()
{
_evictedKeys = new List<string>();
_cache = new FastCache<string, string>(
cleanupJobInterval: 100,
itemEvicted: key => _evictedKeys.Add(key));
}

[TestMethod]
public async Task WhenItemExpires_EvictionCallbackFires()
{
// Arrange
var key = "test-key";
_cache.AddOrUpdate(key, "value", TimeSpan.FromMilliseconds(1));

// Act
await Task.Delay(110); // Wait for expiration

// Assert
Assert.AreEqual(1, _evictedKeys.Count);
Assert.AreEqual(key, _evictedKeys[0]);
}

[TestMethod]
public async Task WhenMultipleItemsExpire_CallbackFiresForEach()
{
// Arrange
var keys = new[] { "key1", "key2", "key3" };
foreach (var key in keys)
{
_cache.AddOrUpdate(key, "value", TimeSpan.FromMilliseconds(1));
}

// Act
await Task.Delay(5); // Wait for 1ms expiration
_cache.EvictExpired();
await Task.Delay(5); // Wait for callback to finish on another thread

// Assert
CollectionAssert.AreEquivalent(keys, _evictedKeys);
}

[TestMethod]
public void WhenItemNotExpired_CallbackDoesNotFire()
{
// Arrange
_cache.AddOrUpdate("key", "value", TimeSpan.FromMinutes(1));

// Act
_cache.EvictExpired();

// Assert
Assert.AreEqual(0, _evictedKeys.Count);
}

[TestMethod]
public async Task AutomaticCleanup_FiresCallback()
{
// Arrange
_cache.AddOrUpdate("key", "value", TimeSpan.FromMilliseconds(1));

// Act
await Task.Delay(110); // Wait for cleanup job

// Assert
Assert.AreEqual(1, _evictedKeys.Count);
}
}

0 comments on commit ad3cfb4

Please sign in to comment.