Last active
June 28, 2023 09:07
-
-
Save deadlydog/3169ff35abc95c4607acf5730a12932b to your computer and use it in GitHub Desktop.
Wraps the .NET IMemoryCache in the Stale Cache pattern to provide Best-Before (Stale) date functionality, allowing apps to achieve better fault tolerance
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Caching; | |
using Microsoft.Extensions.Logging; | |
namespace App; | |
public class ExampleUsage | |
{ | |
private readonly ILogger<ExampleUsage> _logger; | |
private readonly StaleMemoryCache _staleMemoryCache; | |
private readonly IAuthService _authService; | |
private const string AuthTokenCacheKey = "AuthToken"; | |
private readonly TimeSpan TimeBeforeFetchingFreshAuthToken = TimeSpan.FromMinutes(30); | |
private readonly TimeSpan MaxTimeToUseStaleAuthTokenFor = TimeSpan.FromMinutes(120); | |
public ExampleUsage(ILogger<ExampleUsage> logger, StaleMemoryCache staleMemoryCache, IAuthService authService) | |
{ | |
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); | |
_staleMemoryCache = staleMemoryCache ?? throw new ArgumentNullException(nameof(staleMemoryCache)); | |
_authService = authService ?? throw new ArgumentNullException(nameof(authService)); | |
} | |
public async Task<string> GetAuthToken() | |
{ | |
string? authToken; | |
var cacheResult = _staleMemoryCache.TryGetValue(AuthTokenCacheKey, out authToken); | |
// If we have a fresh auth token in the cache, return it. | |
if (cacheResult == StaleMemoryCacheResult.ValueFound) | |
{ | |
return authToken; | |
} | |
// Otherwise, we either don't have an auth token or it is stale, so try to get a fresh one. | |
// (An alternative approach could be to return the stale auth token right away and try to get a fresh one in the background.) | |
var authItem = await GetFreshAuthTokenFromExternalService(); | |
// If we successfully retrieved a fresh auth token, add it to the cache and return it. | |
bool authRetrievedSuccessfully = (authItem is not null); | |
if (authRetrievedSuccessfully) | |
{ | |
authToken = authItem.Token; | |
_staleMemoryCache.Set(AuthTokenCacheKey, authToken, TimeBeforeFetchingFreshAuthToken, MaxTimeToUseStaleAuthTokenFor); | |
return authToken; | |
} | |
// Otherwise we could not get a fresh auth token, so if we have a stale auth token in the cache, return it. | |
bool weHaveACachedAuthToken = (cacheResult == StaleMemoryCacheResult.StaleValueFound); | |
if (weHaveACachedAuthToken) | |
{ | |
_logger.LogWarning("Could not retrieve a fresh auth token, so using a stale cached one instead."); | |
return authToken; | |
} | |
// Otherwise we could not get a fresh auth token and we do not have one in the cache, so throw an exception. | |
throw new Exception("Could not retrieve an auth token and there are no cached ones to use."); | |
} | |
private async Task<AuthItem?> GetFreshAuthTokenFromExternalService() | |
{ | |
AuthItem? authItem = null; | |
try | |
{ | |
authItem = await _authService.GetAuthToken(); | |
} | |
catch (Exception ex) | |
{ | |
_logger.LogError(ex, "Error retrieving auth token."); | |
} | |
return authItem; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Microsoft.Extensions.Caching.Memory; | |
using Microsoft.Extensions.Internal; | |
using Microsoft.Extensions.Logging; | |
namespace Caching; | |
public enum StaleMemoryCacheResult | |
{ | |
ValueFound, | |
StaleValueFound, | |
ValueNotFound | |
} | |
/// <summary> | |
/// This adds a layer of caching on top of the IMemoryCache. | |
/// It allows you to set a time before the item is stale and a time before the item expires. | |
/// This is useful for scenarios where you want to return a stale item from the cache while you refresh the item in the background, | |
/// or when the resource that you retrieve the value from is temporarily unavailable. | |
/// </summary> | |
public class StaleMemoryCache | |
{ | |
private readonly ILogger<StaleMemoryCache> _logger; | |
private readonly IMemoryCache _memoryCache; | |
private readonly ISystemClock _clock; // The clock is only required for unit testing purposes. | |
public StaleMemoryCache(ILogger<StaleMemoryCache> logger, IMemoryCache memoryCache) | |
{ | |
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); | |
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); | |
_clock = new SystemClock(); | |
} | |
/// <summary> | |
/// This constructor is only used for unit testing purposes where you want to be able to control the clock. | |
/// </summary> | |
public StaleMemoryCache(ILogger<StaleMemoryCache> logger, IMemoryCache memoryCache, ISystemClock clock) | |
: this(logger, memoryCache) | |
{ | |
_clock = clock ?? throw new ArgumentNullException(nameof(clock)); | |
} | |
/// <summary> | |
/// Try and retrieve the value from the cache. | |
/// If the item has not expired, the value will be returned along with a result indicating if the value is stale or not. | |
/// If the item has expired, the value returned will be the default value for the type and the result will indicate that the value was not found. | |
/// If you specify the wrong return type for the value, it will be treated as if the item has expired | |
/// (e.g. if you Set the value as an int, but TryGetValue it as a string, the result will be ValueNotFound and the value returned will be null). | |
/// </summary> | |
/// <typeparam name="T">The return type of the value.</typeparam> | |
/// <param name="key">The object identifying the item in the cache.</param> | |
/// <param name="value">The item will be returned in this variable if it is found in the cache, otherwise this will have the default value for the type.</param> | |
/// <returns></returns> | |
public StaleMemoryCacheResult TryGetValue<T>(object key, out T? value) | |
{ | |
bool hasValue = _memoryCache.TryGetValue(key, out CacheItem<T>? item); | |
if (!hasValue || item == null) | |
{ | |
_logger.LogDebug("Cache miss for key '{key}'. Item was either never added to cache or it expired.", key); | |
value = default; | |
return StaleMemoryCacheResult.ValueNotFound; | |
} | |
bool isStale = item.StaleDate < _clock.UtcNow; | |
if (isStale) | |
{ | |
_logger.LogDebug("Cache hit for key '{key}', but item is stale.", key); | |
value = item.Value; | |
return StaleMemoryCacheResult.StaleValueFound; | |
} | |
_logger.LogDebug("Cache hit for key '{key}'.", key); | |
value = item.Value; | |
return StaleMemoryCacheResult.ValueFound; | |
} | |
/// <summary> | |
/// Sets the value in the cache, replacing it if it already exists. | |
/// </summary> | |
/// <typeparam name="T">The type of the value being stored in the cache.</typeparam> | |
/// <param name="key">The object identifying the item in the cache.</param> | |
/// <param name="value">The item to store in the cache.</param> | |
/// <param name="staleDate">The date after which the item is considered to be stale.</param> | |
/// <param name="expiryDate">The date the item expires and is considered removed from the cache.</param> | |
public void Set<T>(object key, T value, DateTimeOffset staleDate, DateTimeOffset expiryDate) | |
{ | |
var cacheItem = new CacheItem<T> | |
{ | |
Value = value, | |
StaleDate = staleDate | |
}; | |
_memoryCache.Set(key, cacheItem, expiryDate); | |
} | |
/// <summary> | |
/// Sets the value in the cache, replacing it if it already exists. | |
/// </summary> | |
/// <typeparam name="T">The type of the value being stored in the cache.</typeparam> | |
/// <param name="key">The object identifying the item in the cache.</param> | |
/// <param name="value">The item to store in the cache.</param> | |
/// <param name="timeBeforeItemIsStale">How long until the item is considered to be stale.</param> | |
/// <param name="timeBeforeItemExpires">How long until the item expires and is considered removed from the cache.</param> | |
public void Set<T>(object key, T value, TimeSpan timeUntilItemIsStale, TimeSpan timeUntilItemExpires) | |
{ | |
var staleDate = _clock.UtcNow.Add(timeUntilItemIsStale); | |
var expiryDate = _clock.UtcNow.Add(timeUntilItemExpires); | |
Set(key, value, staleDate, expiryDate); | |
} | |
private class CacheItem<T> | |
{ | |
public T Value { get; set; } = default!; | |
public DateTimeOffset StaleDate { get; set; } = DateTimeOffset.MinValue; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Caching; | |
using FluentAssertions; | |
using Microsoft.Extensions.Logging.Abstractions; | |
using Microsoft.Extensions.Logging; | |
using Microsoft.Extensions.Caching.Memory; | |
using Microsoft.Extensions.Internal; | |
using NSubstitute; | |
using Xunit; | |
namespace Caching.Tests; | |
public class StaleMemoryCacheTests | |
{ | |
private readonly ILogger<StaleMemoryCache> _nullLogger; | |
public StaleMemoryCacheTests() | |
{ | |
_nullLogger = new NullLoggerFactory().CreateLogger<StaleMemoryCache>(); | |
} | |
public DateTimeOffset DateNow => DateTimeOffset.UtcNow; | |
public DateTimeOffset DateOneMinuteInTheFuture => DateTimeOffset.UtcNow.AddMinutes(1); | |
[Fact] | |
public void GettingTheValue_WhenItemIsNotInTheCache_ReturnsDefaultValue() | |
{ | |
// Arrange | |
var cache = new StaleMemoryCache(_nullLogger, new MemoryCache(new MemoryCacheOptions()), new SystemClock()); | |
// Act | |
var result = cache.TryGetValue("key", out object? itemThatDoesNotExist); | |
// Assert | |
result.Should().Be(StaleMemoryCacheResult.ValueNotFound); | |
itemThatDoesNotExist.Should().BeNull(); | |
} | |
[Fact] | |
public void GettingTheValue_WhenCachedItemIsFresh_ReturnsValidItem() | |
{ | |
// Arrange | |
var clock = Substitute.For<ISystemClock>(); | |
var cache = new StaleMemoryCache(_nullLogger, new MemoryCache(new MemoryCacheOptions() { Clock = clock }), clock); | |
var key = "key"; | |
var value = "value"; | |
// Act | |
clock.UtcNow.Returns(DateNow); | |
cache.Set(key, value, TimeSpan.FromHours(1), TimeSpan.FromHours(2)); | |
var result = cache.TryGetValue(key, out string? item); | |
// Assert | |
result.Should().Be(StaleMemoryCacheResult.ValueFound); | |
item.Should().NotBeNull(); | |
item.Should().Be(value); | |
} | |
[Fact] | |
public void GettingTheValue_WhenCachedItemIsStale_ReturnsStaleItem() | |
{ | |
// Arrange | |
var clock = Substitute.For<ISystemClock>(); | |
var cache = new StaleMemoryCache(_nullLogger, new MemoryCache(new MemoryCacheOptions() { Clock = clock }), clock); | |
var key = "key"; | |
var value = "value"; | |
// Act | |
clock.UtcNow.Returns(DateNow); | |
cache.Set(key, value, TimeSpan.FromSeconds(1), TimeSpan.FromHours(2)); | |
clock.UtcNow.Returns(DateOneMinuteInTheFuture); | |
var result = cache.TryGetValue(key, out string? item); | |
// Assert | |
result.Should().Be(StaleMemoryCacheResult.StaleValueFound); | |
item.Should().NotBeNull(); | |
item.Should().Be(value); | |
} | |
[Fact] | |
public void GettingTheValue_WhenCachedItemIsExpired_ReturnsDefaultValue() | |
{ | |
// Arrange | |
var clock = Substitute.For<ISystemClock>(); | |
var cache = new StaleMemoryCache(_nullLogger, new MemoryCache(new MemoryCacheOptions() { Clock = clock }), clock); | |
var key = "key"; | |
var value = "value"; | |
// Act | |
clock.UtcNow.Returns(DateNow); | |
cache.Set(key, value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)); | |
clock.UtcNow.Returns(DateOneMinuteInTheFuture); | |
var result = cache.TryGetValue(key, out object? item); | |
// Assert | |
result.Should().Be(StaleMemoryCacheResult.ValueNotFound); | |
item.Should().BeNull(); | |
} | |
[Fact] | |
public void GettingTheValue_WhenTheWrongTypeIsSpecified_ReturnsDefaultValue() | |
{ | |
// Arrange | |
var clock = Substitute.For<ISystemClock>(); | |
var cache = new StaleMemoryCache(_nullLogger, new MemoryCache(new MemoryCacheOptions() { Clock = clock }), clock); | |
var key = "key"; | |
var value = "value"; | |
// Act | |
clock.UtcNow.Returns(DateNow); | |
cache.Set(key, value, TimeSpan.FromHours(1), TimeSpan.FromHours(2)); | |
var result = cache.TryGetValue(key, out int? item); | |
// Assert | |
result.Should().Be(StaleMemoryCacheResult.ValueNotFound); | |
item.Should().BeNull(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment