172 lines
6.7 KiB
C#
172 lines
6.7 KiB
C#
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);
|
|
}
|
|
}
|