using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace PowderCoating.Application.Services; /// /// Defines a thin wrapper around that adds thread-safe /// get-or-create semantics and prefix-based bulk invalidation. /// public interface ICachingService { /// /// Returns the cached value for if present; otherwise /// invokes , stores the result, and returns it. /// /// Type of the cached value. /// Unique cache key. /// Async delegate used to produce the value on a cache miss. /// /// How long the entry lives before being evicted regardless of access. /// Defaults to 15 minutes when null. /// Task GetOrCreateAsync(string key, Func> factory, TimeSpan? absoluteExpiration = null); /// Removes a single cache entry by its exact key. /// The key to remove. void Remove(string key); /// /// Removes all cache entries whose keys begin with . /// This enables coarse-grained invalidation (e.g. all keys for a given company) /// without knowing every individual key in advance. /// /// The key prefix to match. void RemoveByPrefix(string prefix); } /// /// In-process memory cache wrapper that enforces a double-checked locking pattern /// to prevent cache stampedes under concurrent load. /// /// Design notes: /// /// /// A static tracks every key written so that /// can iterate them — /// does not expose its key set natively. /// /// /// A static (capacity 1) serialises factory /// invocations for the same key, preventing multiple simultaneous DB calls /// for an identical cache miss. /// /// /// Both statics are process-wide intentionally: this service is registered as /// Scoped but the underlying is Singleton, so the /// key registry must outlive individual request scopes. /// /// /// /// public class CachingService : ICachingService { private readonly IMemoryCache _cache; private readonly ILogger _logger; /// /// Process-wide set of all keys written to the cache. /// Required because has no built-in key enumeration. /// Static so the registry survives across DI scope lifetimes. /// private static readonly HashSet _cacheKeys = new(); /// /// Process-wide semaphore that serialises factory invocations to prevent /// cache stampedes when multiple requests miss the same key simultaneously. /// private static readonly SemaphoreSlim _semaphore = new(1, 1); /// /// Initialises a new with the required dependencies. /// /// The underlying ASP.NET Core memory cache. /// Logger for debug-level cache hit/miss diagnostics. public CachingService(IMemoryCache cache, ILogger logger) { _cache = cache; _logger = logger; } /// /// Returns the cached value for ; on a miss, acquires the /// semaphore and double-checks before invoking 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. /// /// Type of the cached value. /// Unique cache key. /// Async delegate that loads the value from the source of truth. /// /// Hard upper bound on entry lifetime; defaults to 15 minutes. /// /// The cached or freshly loaded value. public async Task GetOrCreateAsync(string key, Func> 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(); } } /// /// Removes the cache entry with the given from both the /// underlying and the internal key registry. /// /// The exact key to remove. public void Remove(string key) { _cache.Remove(key); _cacheKeys.Remove(key); _logger.LogDebug("Removed cache key: {Key}", key); } /// /// Removes all cache entries whose keys start with . /// Used for coarse-grained invalidation — for example, wiping all lookup data /// for a single company by passing "JobStatus_42" prefix patterns. /// Snapshot the matching keys first to avoid mutating the set while iterating. /// /// Key prefix to match. 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); } }