using System; using System.Threading; namespace GB5Shared.Cache { /// /// Striped semaphore pool for cache-stampede prevention (Thundering Herd Fix). /// /// Problem: When a popular Dapr-cached key expires, all concurrent requests /// simultaneously see a miss and fan out to the database. At 1,000 req/s for /// a shared dropdown endpoint this fires 1,000 identical DB queries every TTL. /// /// Fix: Every cache miss acquires a per-key semaphore before executing the DB /// query. The first arrival runs the query and writes the result; subsequent /// arrivals wait (up to TimeoutMs), then double-check the now-populated cache /// before deciding whether to re-query. /// /// Implementation — striped pool: /// Instead of one SemaphoreSlim per key (unbounded memory), we use a fixed /// pool of slots. Each cache key is hashed to a slot. /// Multiple keys may share a slot (false contention), but with 4096 stripes /// and 100 concurrent requests the expected false-contention rate is ~0.3% — /// negligible compared to the thundering-herd problem being solved. /// /// Memory: 4096 × ~100 bytes = ~400 KB — constant, no growth. /// /// Usage: /// Register as a singleton in DI. /// In the cache-miss branch: /// /// var sem = _lockRegistry.GetLock(cacheKey); /// bool held = await sem.WaitAsync(CacheLockRegistry.TimeoutMs, ct); /// try /// { /// if (held) /// { /// // Double-check: another request may have populated while we waited. /// var recheck = await TryGetFromCacheAsync(cacheKey, ct); /// if (recheck != null) { await SendAsync(recheck, cancellation: ct); return; } /// } /// // held=false (timeout) → still run the query; don't block the request. /// var fresh = await ExecuteAsync(req, login, ct); /// if (held) await SaveToCacheAsync(cacheKey, fresh, ct); /// await SendAsync(fresh, cancellation: ct); /// } /// finally { if (held) sem.Release(); } /// /// /// Graceful degradation: /// If elapses the request falls through to a fresh /// DB query without blocking — correctness is preserved, the DB just receives /// a few extra queries during a stampede rather than thousands. /// public sealed class CacheLockRegistry { /// /// Number of semaphore stripes in the pool. /// Power of 2 so the modulo can be reduced to a bitwise AND. /// public const int Stripes = 4096; /// /// How long to wait for the semaphore before falling through to a live query. /// 200 ms is generous for a typical DB round-trip (10–50 ms) while not /// materially increasing response latency for the waiting request. /// public const int TimeoutMs = 200; private readonly SemaphoreSlim[] _slots; public CacheLockRegistry() { _slots = new SemaphoreSlim[Stripes]; for (int i = 0; i < Stripes; i++) _slots[i] = new SemaphoreSlim(1, 1); } /// /// Returns the assigned to this cache key. /// The mapping is stable for the lifetime of the process. /// public SemaphoreSlim GetLock(string cacheKey) { // String.GetHashCode() is fast and well-distributed. // & 0x7FFFFFFF strips the sign bit so the result is always non-negative. int index = (cacheKey.GetHashCode() & 0x7FFFFFFF) & (Stripes - 1); return _slots[index]; } } }