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