In memory cache and eviction with C#
Memory cache is a fast and easy way to retrieve data, objects, lists, etc.; that are accessed constantly.
Not everything needs to be in-memory, you can use Redis as well to accomplish the same results. Bear in mind that memory may be "infinite" in the cloud, but costs add up really fast.
With that disclaimer out of the way.
In-memory cache is a life saver.
Personally, I use it for arrays, lists, certificates, hash tables and values that I know will be shared among sessions or constantly accessed in the application.
As a rule, you want to expire the cache at least once a week. In the following code we will be doing that and make a generic class that takes a delegate in order to populate the cache with a generic type.
Data Time Helper
We will be creating a helper class to calculate the timespan from a CRON expression.

CRON expressions are awesome, but if you are like me, I do not remember the structure, just like regular expressions, but using chat gpt you can come with a quick way to create one.
You can use Bing's copilot which is free for simple tasks

Prompt: create a cron expression that executes every Sunday at 11 pm
Result: 0 23 * * 0
Now that we have our CRON expression, we will be adding the functionality to the helper class, and we will be using CRONOS.
CRONOS will do the parsing and returning a date time object or time span. Also, we will take in account the time zone for the expression as the hosting of the application will determine what hour needs to be.
The helper class will have 2 methods.
- Round to the nearest or up or down minute (not used for this example)
- Obtain the timespan from the CRON expression taking in account the time zone (Pacific Standard Time by default).
And a enumeration that determines the rounding functionality.
/// <summary>
/// Date Time Helper class.
/// </summary>
public static class DateTimeHelper
{
#region Methods
/// <summary>
/// Rounds the specified DateTime to the nearest minute.
/// </summary>
/// <param name="dt">The DateTime to round.</param>
/// <param name="minutesToRound">The number of minutes to round to.</param>
/// <param name="direction">The rounding direction.</param>
/// <returns>The rounded DateTime.</returns>
public static DateTime RoundToNearestMinute(this DateTime dt, int minutesToRound, RoundingDirection direction)
{
if (minutesToRound == 0) //can be > 60 mins
return dt;
var d = TimeSpan.FromMinutes(minutesToRound); //this can be passed as a parameter, or use any timespan unit FromDays, FromHours, etc.
long delta = 0;
var modTicks = dt.Ticks % d.Ticks;
switch (direction)
{
case RoundingDirection.Up:
delta = (modTicks != 0) ? d.Ticks - modTicks : 0;
break;
case RoundingDirection.Down:
delta = -modTicks;
break;
case RoundingDirection.Nearest:
{
bool roundUp = modTicks > (d.Ticks / 2);
var offset = roundUp ? d.Ticks : 0;
delta = offset - modTicks;
break;
}
}
return new DateTime(dt.Ticks + delta, dt.Kind);
}
/// <summary>
/// Calculates the TimeSpan relative to the current time based on the given cron expression.
/// </summary>
/// <param name="cronExpression">The cron expression.</param>
/// <param name="timeZone">The time zone identifier (optional, default is "Pacific Standard Time").</param>
/// <returns>The calculated TimeSpan.</returns>
public static TimeSpan TimeSpanRelativeToNow(string cronExpression, string timeZone = "Pacific Standard Time")
{
var nearest = DateTime.Now.RoundToNearestMinute(1, RoundingDirection.Down);
var nearestOccurrence = DateTimeOffset.Parse(nearest.ToString());
var expression = CronExpression.Parse(cronExpression);
var nextOccurrence = expression.GetNextOccurrence(nearestOccurrence, TimeZoneInfo.FindSystemTimeZoneById(timeZone), inclusive: false);
if (nextOccurrence.HasValue)
{
var next = nextOccurrence.Value.ToLocalTime().DateTime;
return next - nearest;
}
else
{
throw new InvalidOperationException("No next occurrence found for the given cron expression.");
}
}
#endregion Methods
}
/// <summary>
/// Rounding direction.
/// </summary>
public enum RoundingDirection
{
/// <summary>
/// Rounds up.
/// </summary>
Up,
/// <summary>
/// Rounds down.
/// </summary>
Down,
/// <summary>
/// Rounds to the nearest.
/// </summary>
Nearest
}
Memory Class
The in-memory class will take a generic return type, preferably serializable but you can add a guard clause if needed.
Since I need it to be thread safe, we will be using a semaphore and a lock object.
A delegate will be responsible of returning the type once the heavy lifting is done and be placed into memory.
Finally, we are checking if a CRON expression is passed (none is the default and will let the item to never expire) and if it is valid, we will be using the date time helper to obtain a timespan and expire the memory item.
/// <summary>
/// Represents a memory cache for storing and retrieving items.
/// </summary>
/// <typeparam name="TItem">The type of the items stored in the cache.</typeparam>
public class Memory<TItem>
{
#region Members
private readonly MemoryCache _memoryCache = new MemoryCache(new MemoryCacheOptions());
private readonly ConcurrentDictionary<object, SemaphoreSlim> _locks = new();
#endregion Members
#region Methods
/// <summary>
/// Gets the item from the cache with the specified key. If the item does not exist in the cache, it will be created using the provided delegate function.
/// </summary>
/// <param name="key">The key of the item in the cache.</param>
/// <param name="createItem">The delegate function to create the item if it does not exist in the cache.</param>
/// <param name="cacheEntry">The existing cache entry, if any.</param>
/// <param name="cronExpression">The cron expression for setting the cache expiration, or "none" if no expiration is set.</param>
/// <param name="timeZone">The time zone for the cron expression.</param>
/// <returns>The item from the cache.</returns>
public async Task<TItem> GetOrCreate(object key, Func<Task<TItem>> createItem, TItem? cacheEntry, string cronExpression = "none", string timeZone = "Pacific Standard Time")
{
if (!_memoryCache.TryGetValue(key, out cacheEntry)) // Look for cache key.
{
SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1));
// Notify all threads trying to access this value to await creation
await mylock.WaitAsync();
try
{
if (!_memoryCache.TryGetValue(key, out cacheEntry))
{
// Key not in cache, so get data, this is a delegate function
cacheEntry = await createItem();
if (cronExpression == "none")
_memoryCache.Set(key, cacheEntry);
else
{
// Obtain absolute expiration relative to now in time span format
var timeSpan = DateTimeHelper.TimeSpanRelativeToNow(cronExpression, timeZone);
_memoryCache.Set(key, cacheEntry, timeSpan);
}
}
}
finally
{
mylock.Release();
}
}
// Ensure cacheEntry is not null before returning
return cacheEntry ?? throw new InvalidOperationException("Cache entry creation failed.");
}
/// <summary>
/// Deletes the cache entry with the specified key.
/// </summary>
/// <param name="key">The key of the cache entry to delete.</param>
/// <returns>True if the cache entry was successfully deleted; otherwise, false.</returns>
public async Task<bool> DeleteEntry(object key)
{
SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1));
//notify all threads trying to access this value to await creation
await mylock.WaitAsync();
try
{
_memoryCache.Remove(key);
}
finally
{
mylock.Release();
}
return true;
}
#endregion Methods
}
Usage
You might ask, why a delegate?
Well, I want a separate function to handle the creation of the item. For example, if I need to retrieve a certificate for Azure Key Vault, it is separated from the in-memory functionality.
Other use cases
- API responses
- LINQ-To IEnumerable result
- Dictionary
In this example, it is just a string, but you get the idea.
static async Task Main(string[] args)
{
// Create an instance of Memory<TItem> with the desired type
var memory = new MemoryCacheLibrary.Memory<string>();
// Define the key for the cache entry
object key = "myCacheKey";
// Define the delegate function to create the item if it does not exist in the cache
Func<Task<string>> createItem = async () =>
{
// Simulate some time-consuming operation to create the item
await Task.Delay(1000);
return "Cached Item";
};
// Define the cron expression for cache expiration
string cronExpression = "0 0 * * *"; // Run every day at midnight
// Get or create the item from the cache with the specified key and cron expression
string cacheEntry = await memory.GetOrCreate(key, createItem, cronExpression, "Pacific Standard Time");
// Print the cache entry
Console.WriteLine(cacheEntry);
// Delete the cache entry with the specified key
await memory.DeleteEntry(key);
}
Use a singleton/static for the in-memory class, this will ensure there is only one instance, and it has persisted during the lifetime of the application.
//declaration
builder.Services.AddSingleton((c) => {
return new MemoryCacheLibrary.Memory<string>();
});
//injection
public class SomeClass
{
private Memory<string> _inMemory;
public SomeClass(Memory<string> inMemory)
{
_inMemory = inMemory;
}
public async Task<bool> DoSomething()
{
Func<Task<string>> createItem = async () =>
{
// Simulate some time-consuming operation to create the item
await Task.Delay(1000);
return "Cached Item";
};
// Define the cron expression for cache expiration
string cronExpression = "0 0 * * *"; // Run every day at midnight
string cacheEntry = await memory.GetOrCreate(key, createItem, cronExpression, "Pacific Standard Time");
}
}
Happy coding!!!