using System.Collections.Concurrent; using System.Collections.Immutable; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Policy.Engine.Options; namespace StellaOps.Policy.Engine.Caching; /// /// In-memory implementation of policy evaluation cache. /// Uses time-based eviction with configurable TTL. /// public sealed class InMemoryPolicyEvaluationCache : IPolicyEvaluationCache { private readonly ConcurrentDictionary _cache; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly TimeSpan _defaultTtl; private readonly int _maxItems; private long _totalRequests; private long _cacheHits; private long _cacheMisses; private long _evictionCount; public InMemoryPolicyEvaluationCache( ILogger logger, TimeProvider timeProvider, IOptions options) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _cache = new ConcurrentDictionary(StringComparer.Ordinal); var cacheOptions = options?.Value.EvaluationCache ?? new PolicyEvaluationCacheOptions(); _defaultTtl = TimeSpan.FromMinutes(cacheOptions.DefaultTtlMinutes); _maxItems = cacheOptions.MaxItems; } public Task GetAsync( PolicyEvaluationCacheKey key, CancellationToken cancellationToken = default) { Interlocked.Increment(ref _totalRequests); var cacheKey = key.ToCacheKey(); var now = _timeProvider.GetUtcNow(); if (_cache.TryGetValue(cacheKey, out var item) && item.ExpiresAt > now) { Interlocked.Increment(ref _cacheHits); return Task.FromResult(new PolicyEvaluationCacheResult(item.Entry, true, CacheSource.InMemory)); } Interlocked.Increment(ref _cacheMisses); // Remove expired entry if present if (item != null) { _cache.TryRemove(cacheKey, out _); } return Task.FromResult(new PolicyEvaluationCacheResult(null, false, CacheSource.None)); } public async Task GetBatchAsync( IReadOnlyList keys, CancellationToken cancellationToken = default) { var found = new Dictionary(); var notFound = new List(); var hits = 0; var misses = 0; foreach (var key in keys) { var result = await GetAsync(key, cancellationToken).ConfigureAwait(false); if (result.Entry != null) { found[key] = result.Entry; hits++; } else { notFound.Add(key); misses++; } } return new PolicyEvaluationCacheBatch { Found = found, NotFound = notFound, CacheHits = hits, CacheMisses = misses, InMemoryHits = hits, RedisHits = 0, }; } public Task SetAsync( PolicyEvaluationCacheKey key, PolicyEvaluationCacheEntry entry, CancellationToken cancellationToken = default) { EnsureCapacity(); var cacheKey = key.ToCacheKey(); var now = _timeProvider.GetUtcNow(); var expiresAt = entry.ExpiresAt > now ? entry.ExpiresAt : now.Add(_defaultTtl); var item = new CacheItem(entry, expiresAt); _cache[cacheKey] = item; return Task.CompletedTask; } public Task SetBatchAsync( IReadOnlyDictionary entries, CancellationToken cancellationToken = default) { EnsureCapacity(entries.Count); var now = _timeProvider.GetUtcNow(); foreach (var (key, entry) in entries) { var cacheKey = key.ToCacheKey(); var expiresAt = entry.ExpiresAt > now ? entry.ExpiresAt : now.Add(_defaultTtl); var item = new CacheItem(entry, expiresAt); _cache[cacheKey] = item; } return Task.CompletedTask; } public Task InvalidateAsync( PolicyEvaluationCacheKey key, CancellationToken cancellationToken = default) { var cacheKey = key.ToCacheKey(); _cache.TryRemove(cacheKey, out _); return Task.CompletedTask; } public Task InvalidateByPolicyDigestAsync( string policyDigest, CancellationToken cancellationToken = default) { var prefix = $"pe:{policyDigest}:"; var keysToRemove = _cache.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList(); foreach (var key in keysToRemove) { _cache.TryRemove(key, out _); } _logger.LogDebug("Invalidated {Count} cache entries for policy digest {Digest}", keysToRemove.Count, policyDigest); return Task.CompletedTask; } public PolicyEvaluationCacheStats GetStats() { return new PolicyEvaluationCacheStats { TotalRequests = Interlocked.Read(ref _totalRequests), CacheHits = Interlocked.Read(ref _cacheHits), CacheMisses = Interlocked.Read(ref _cacheMisses), InMemoryHits = Interlocked.Read(ref _cacheHits), RedisHits = 0, RedisFallbacks = 0, ItemCount = _cache.Count, EvictionCount = Interlocked.Read(ref _evictionCount), }; } private void EnsureCapacity(int additionalItems = 1) { if (_cache.Count + additionalItems <= _maxItems) { return; } var now = _timeProvider.GetUtcNow(); var itemsToRemove = _cache.Count + additionalItems - _maxItems + (_maxItems / 10); // First, remove expired items var expiredKeys = _cache .Where(kvp => kvp.Value.ExpiresAt <= now) .Select(kvp => kvp.Key) .ToList(); foreach (var key in expiredKeys) { if (_cache.TryRemove(key, out _)) { Interlocked.Increment(ref _evictionCount); itemsToRemove--; } } if (itemsToRemove <= 0) { return; } // Then, remove oldest items by expiration time var oldestKeys = _cache .OrderBy(kvp => kvp.Value.ExpiresAt) .Take(itemsToRemove) .Select(kvp => kvp.Key) .ToList(); foreach (var key in oldestKeys) { if (_cache.TryRemove(key, out _)) { Interlocked.Increment(ref _evictionCount); } } _logger.LogDebug( "Evicted {EvictedCount} evaluation cache entries (expired: {ExpiredCount}, oldest: {OldestCount})", expiredKeys.Count + oldestKeys.Count, expiredKeys.Count, oldestKeys.Count); } private sealed record CacheItem(PolicyEvaluationCacheEntry Entry, DateTimeOffset ExpiresAt); } /// /// Configuration options for policy evaluation cache. /// public sealed class PolicyEvaluationCacheOptions { /// /// Default TTL for cache entries in minutes. /// public int DefaultTtlMinutes { get; set; } = 30; /// /// Maximum number of items in the in-memory cache. /// public int MaxItems { get; set; } = 50000; /// /// Whether to enable Redis as a distributed cache layer. /// public bool EnableRedis { get; set; } /// /// Redis connection string. /// public string? RedisConnectionString { get; set; } /// /// Redis key prefix for policy evaluations. /// public string RedisKeyPrefix { get; set; } = "stellaops:pe:"; /// /// Whether to use hybrid mode (in-memory + Redis). /// public bool HybridMode { get; set; } = true; /// /// Timeout for Redis operations in milliseconds. /// public int RedisTimeoutMs { get; set; } = 100; }