using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Dapr.Client; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.ResponseStandard; using StackExchange.Redis; namespace GB5Shared.DaprCache { public class KeyInvalidate { private readonly DaprClient _daprClient; private readonly IConnectionMultiplexer? _redis; private readonly CacheKeyGeneration _keyGeneration = new(); // Matches statestore.yaml's "keyPrefix" metadata — Dapr's Redis state component // stores every cached response as "{keyPrefix}{appId}||{stateKey}" (confirmed live: // "gb5_||"). A wildcard scan on the prefix alone is safe and catches every // cached response regardless of which host/app-id wrote it. private const string StateStoreKeyPattern = "gb5_*"; // IConnectionMultiplexer is only registered in hosts that opted into Redis+HybridCache // (currently FrameworkSL) — KeyInvalidate itself is used from ~30 other SL hosts that // never register it, so this MUST stay optional/nullable. The default-parameter form // lets the built-in DI container fall back to null there instead of throwing. public KeyInvalidate(DaprClient daprClient, IConnectionMultiplexer? redis = null) { _daprClient = daprClient; _redis = redis; } /// /// Deletes one specific cache entry via Dapr's state API. Use when the caller already /// knows the exact cache key — e.g. one captured from a prior read, or reproduced via /// . /// public async Task InvalidateCache(string key) { try { await _daprClient.DeleteStateAsync("statestore", key); return "Cache invalidated successfully."; } catch (Exception) { throw; } } /// /// Reproduces the exact SHA-256 cache key a read endpoint's GetCacheKey() would have /// produced for this (ObjectId, ObjectTypeId, CacheKeyLevel, LoginDTO[, CriteriaDTO]) /// tuple (via the same used by BaseEndpoint), then /// deletes it. Lets a BLL precisely invalidate one specific cached response — e.g. "this /// one user's dashboard list" or "this one dashboard's page list" — without flushing /// everything, whenever the affected key(s) are individually enumerable at write time. /// Only correct when the LoginDTO passed here has the same ClientId/RoleId/UserId (as /// relevant to CacheKeyLevel) that the read endpoint's own GetCacheKey() used. /// public Task InvalidateByKey( object objectId, int objectTypeId, int cacheKeyLevel, LoginDTO loginDTO, CriteriaDTO? criteriaDTO = null) { var key = _keyGeneration.KeyGeneration(objectId, objectTypeId, cacheKeyLevel, loginDTO, criteriaDTO); return InvalidateCache(key); } /// /// Deletes every cached entry whose literal key contains , /// via the same direct-Redis SCAN+DEL technique as , but /// scoped instead of global. Use when a write can affect a whole family of cache entries /// that share a literal substring (e.g. a wildcard-dimension row in a resolve-style cache, /// where the exact set of concrete keys it could affect isn't enumerable at write time) — /// the alternative to a full flush for that case. /// No-ops (returns a status string, doesn't throw) when no Redis connection is available, /// same as . /// public async Task InvalidateByPattern(string keyFragment) { if (_redis is null || !_redis.IsConnected) return "Cache invalidation skipped: no Redis connection available in this host."; var pattern = $"gb5_*{keyFragment}*"; var deleted = 0; foreach (var endpoint in _redis.GetEndPoints()) { var server = _redis.GetServer(endpoint); if (server.IsReplica) continue; var db = _redis.GetDatabase(); var batch = new List(500); await foreach (var redisKey in server.KeysAsync(pattern: pattern).ConfigureAwait(false)) { batch.Add(redisKey); if (batch.Count < 500) continue; deleted += (int)await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false); batch.Clear(); } if (batch.Count > 0) deleted += (int)await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false); } return $"Pattern cache invalidated ({deleted} keys removed)."; } /// /// key == "-1": full cache flush. Dapr's generic state API has no bulk-delete operation /// (a single DeleteStateAsync call against a fixed sentinel key with custom metadata is /// NOT honored by the standard Redis state component — confirmed live: this previously /// returned success while leaving every cached entry untouched). This bypasses Dapr and /// scans+deletes every "gb5_*" key directly against the underlying Redis connection. /// Use only for writes whose blast radius can't be enumerated (e.g. a Shared/Role /// Dashboard visible to an unbounded set of users) — correctness over cache-hit-rate, /// for rare/admin-driven writes only. Prefer or /// whenever the affected key(s) are actually known. /// Any key other than "-1": same as . /// public async Task AllInvalidateCache(string key) { if (key != "-1") return await InvalidateCache(key); if (_redis is null || !_redis.IsConnected) return "Cache flush skipped: no Redis connection available in this host."; var deleted = 0; foreach (var endpoint in _redis.GetEndPoints()) { var server = _redis.GetServer(endpoint); if (server.IsReplica) continue; var db = _redis.GetDatabase(); var batch = new List(500); await foreach (var redisKey in server.KeysAsync(pattern: StateStoreKeyPattern).ConfigureAwait(false)) { batch.Add(redisKey); if (batch.Count < 500) continue; deleted += (int)await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false); batch.Clear(); } if (batch.Count > 0) deleted += (int)await db.KeyDeleteAsync(batch.ToArray()).ConfigureAwait(false); } return $"All cache invalidated successfully ({deleted} keys removed)."; } } }