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);
}
}