Initial commit
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a thin wrapper around <see cref="IMemoryCache"/> that adds thread-safe
|
||||
/// get-or-create semantics and prefix-based bulk invalidation.
|
||||
/// </summary>
|
||||
public interface ICachingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the cached value for <paramref name="key"/> if present; otherwise
|
||||
/// invokes <paramref name="factory"/>, stores the result, and returns it.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the cached value.</typeparam>
|
||||
/// <param name="key">Unique cache key.</param>
|
||||
/// <param name="factory">Async delegate used to produce the value on a cache miss.</param>
|
||||
/// <param name="absoluteExpiration">
|
||||
/// How long the entry lives before being evicted regardless of access.
|
||||
/// Defaults to 15 minutes when <c>null</c>.
|
||||
/// </param>
|
||||
Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null);
|
||||
|
||||
/// <summary>Removes a single cache entry by its exact key.</summary>
|
||||
/// <param name="key">The key to remove.</param>
|
||||
void Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all cache entries whose keys begin with <paramref name="prefix"/>.
|
||||
/// This enables coarse-grained invalidation (e.g. all keys for a given company)
|
||||
/// without knowing every individual key in advance.
|
||||
/// </summary>
|
||||
/// <param name="prefix">The key prefix to match.</param>
|
||||
void RemoveByPrefix(string prefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-process memory cache wrapper that enforces a double-checked locking pattern
|
||||
/// to prevent cache stampedes under concurrent load.
|
||||
/// <para>
|
||||
/// Design notes:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// A static <see cref="HashSet{T}"/> tracks every key written so that
|
||||
/// <see cref="RemoveByPrefix"/> can iterate them — <see cref="IMemoryCache"/>
|
||||
/// does not expose its key set natively.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// A static <see cref="SemaphoreSlim"/> (capacity 1) serialises factory
|
||||
/// invocations for the same key, preventing multiple simultaneous DB calls
|
||||
/// for an identical cache miss.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// Both statics are process-wide intentionally: this service is registered as
|
||||
/// Scoped but the underlying <see cref="IMemoryCache"/> is Singleton, so the
|
||||
/// key registry must outlive individual request scopes.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CachingService : ICachingService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<CachingService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide set of all keys written to the cache.
|
||||
/// Required because <see cref="IMemoryCache"/> has no built-in key enumeration.
|
||||
/// Static so the registry survives across DI scope lifetimes.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> _cacheKeys = new();
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide semaphore that serialises factory invocations to prevent
|
||||
/// cache stampedes when multiple requests miss the same key simultaneously.
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new <see cref="CachingService"/> with the required dependencies.
|
||||
/// </summary>
|
||||
/// <param name="cache">The underlying ASP.NET Core memory cache.</param>
|
||||
/// <param name="logger">Logger for debug-level cache hit/miss diagnostics.</param>
|
||||
public CachingService(IMemoryCache cache, ILogger<CachingService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached value for <paramref name="key"/>; on a miss, acquires the
|
||||
/// semaphore and double-checks before invoking <paramref name="factory"/> to
|
||||
/// prevent multiple simultaneous DB hits for the same key.
|
||||
/// Combined absolute + sliding expiration means infrequently-accessed entries
|
||||
/// are evicted early while hot entries live up to the absolute limit.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the cached value.</typeparam>
|
||||
/// <param name="key">Unique cache key.</param>
|
||||
/// <param name="factory">Async delegate that loads the value from the source of truth.</param>
|
||||
/// <param name="absoluteExpiration">
|
||||
/// Hard upper bound on entry lifetime; defaults to 15 minutes.
|
||||
/// </param>
|
||||
/// <returns>The cached or freshly loaded value.</returns>
|
||||
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out T cachedValue))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for key: {Key}", key);
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_cache.TryGetValue(key, out cachedValue))
|
||||
{
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache miss for key: {Key}. Loading from source...", key);
|
||||
var value = await factory();
|
||||
|
||||
var cacheEntryOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(15),
|
||||
SlidingExpiration = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
_cache.Set(key, value, cacheEntryOptions);
|
||||
_cacheKeys.Add(key);
|
||||
|
||||
_logger.LogDebug("Cached value for key: {Key}", key);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cache entry with the given <paramref name="key"/> from both the
|
||||
/// underlying <see cref="IMemoryCache"/> and the internal key registry.
|
||||
/// </summary>
|
||||
/// <param name="key">The exact key to remove.</param>
|
||||
public void Remove(string key)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
_cacheKeys.Remove(key);
|
||||
_logger.LogDebug("Removed cache key: {Key}", key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all cache entries whose keys start with <paramref name="prefix"/>.
|
||||
/// Used for coarse-grained invalidation — for example, wiping all lookup data
|
||||
/// for a single company by passing <c>"JobStatus_42"</c> prefix patterns.
|
||||
/// Snapshot the matching keys first to avoid mutating the set while iterating.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Key prefix to match.</param>
|
||||
public void RemoveByPrefix(string prefix)
|
||||
{
|
||||
var keysToRemove = _cacheKeys.Where(k => k.StartsWith(prefix)).ToList();
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
Remove(key);
|
||||
}
|
||||
_logger.LogDebug("Removed {Count} cache keys with prefix: {Prefix}", keysToRemove.Count, prefix);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user