A couple of months ago we had been tasked to POC somewhat similar. In our case we needed Memoization, which caches the results of an operation for each input for a specific period of time. In other words if you issue the same operation (call the same method with the same input) and you are in the predefined time range then the response is served from a cache otherwise it performs the original request.
First we have introduced the following helper class:
class ValueWithTTL<T>
{
public Lazy<T> Result { get; set; }
public DateTime ExpiresAt { get; set; }
}
The ExpiresAt represents a time in a future when the Result becomes stale.
We have used a ConcurrentDictionary to store the cached results.
Here is the simplified version of the Memoizer helper class:
public static class Memoizer
{
public static Func<K, V> Memoize<K, V>(this Func<K, V> toBeMemoized, int ttlInMs = 5*1000)
where K : IComparable
{
var memoizedValues = new ConcurrentDictionary<K, ValueWithTTL<V>>();
var ttl = TimeSpan.FromMilliseconds(ttlInMs);
return (input) =>
{
if (memoizedValues.TryGetValue(input, out var valueWithTtl))
{
if (DateTime.UtcNow >= valueWithTtl.ExpiresAt)
{
memoizedValues.TryRemove(input, out _);
valueWithTtl = null;
Console.WriteLine($"!!!'{input}' has expired");
}
}
if (valueWithTtl != null)
return valueWithTtl.Result.Value;
var toBeCached = new Lazy<V>(() => toBeMemoized(input));
var toBeExpired = DateTime.UtcNow.AddMilliseconds(ttlInMs);
var toBeCachedWithTimestamp = new ValueWithTTL<V> { Result = toBeCached, ExpiresAt = toBeExpired};
memoizedValues.TryAdd(input, toBeCachedWithTimestamp);
return toBeCachedWithTimestamp.Result.Value;
};
}
}
- It receives a
Func which will be executed only if the given input is not present inside the memoizedValues or if it does present but the ExpiresAt is smaller than now.
- The
Memoize returns a Func with the same signature as the toBeMemoized, so it will nicely work as a decorator or a wrapper.
Here is the sync probe:
private static readonly WebClient wclient = new WebClient();
private static string[] uris = { "http://google.com", "http://9gag.com", "http://stackoverflow.com", "http://gamepod.hu", "http://google.com", "http://google.com", "http://stackoverflow.com" };
static void SyncProbe(IEnumerable<string> uris)
{
var getAndCacheContent = Memoizer.Memoize<string, string>(wclient.DownloadString);
var rand = new Random();
foreach (var uri in uris)
{
var sleepDuration = rand.Next() % 1500;
Thread.Sleep(sleepDuration);
Console.WriteLine($"Slept: {sleepDuration}ms");
var sp = Stopwatch.StartNew();
_ = getAndCacheContent(uri);
sp.Stop();
Console.WriteLine($"'{uri}' request took {sp.ElapsedMilliseconds}ms");
}
}
And here is the async probe:
private static readonly HttpClient client = new HttpClient();
private static string[] uris = { "http://google.com", "http://9gag.com", "http://stackoverflow.com", "http://gamepod.hu", "http://google.com", "http://google.com", "http://stackoverflow.com" };
static async Task AsyncProbe(IEnumerable<string> uris)
{
var getAndCacheContent = Memoizer.Memoize<string, Task<string>>(client.GetStringAsync);
var downloadTasks = new List<Task>();
var rand = new Random();
foreach (var uri in uris)
{
var sleepDuration = rand.Next() % 1500;
await Task.Delay(sleepDuration);
Console.WriteLine($"Slept: {sleepDuration}ms");
downloadTasks.Add(Task.Run(async () =>
{
var sp = Stopwatch.StartNew();
_ = await getAndCacheContent(uri);
sp.Stop();
Console.WriteLine($"'{uri}' request took {sp.ElapsedMilliseconds}ms");
}));
}
await Task.WhenAll(downloadTasks.ToArray());
}
Finally a sample output
Slept: 983ms
'http://google.com' request took 416ms
Slept: 965ms
'http://9gag.com' request took 601ms
Slept: 442ms
'http://stackoverflow.com' request took 803ms
Slept: 1047ms
'http://gamepod.hu' request took 267ms
Slept: 844ms
!!!'http://google.com' has expired
'http://google.com' request took 201ms
Slept: 372ms
'http://google.com' request took 0ms
Slept: 302ms
'http://stackoverflow.com' request took 0ms
- As you can see we have issued 3 requests against google and 2 of them has been executed normally and 1 of them served from the cache. The second attempt could not be served from the cache because it became stale.
- We have issued 2 requests against stackoverflow and the first one executed normally and the second one served from the cache.
This was just a POC so there are plenty room for improvement:
- Make the
memoizedValues bounded and use some eviction policy
- Make use of
WeakReference (1)
- Make use of
MemoryCache despite its known limitation
- etc.