Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -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);
}
}