up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of policy evaluation cache.
|
||||
/// Uses time-based eviction with configurable TTL.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPolicyEvaluationCache : IPolicyEvaluationCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheItem> _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryPolicyEvaluationCache> _logger;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
private readonly int _maxItems;
|
||||
|
||||
private long _totalRequests;
|
||||
private long _cacheHits;
|
||||
private long _cacheMisses;
|
||||
private long _evictionCount;
|
||||
|
||||
public InMemoryPolicyEvaluationCache(
|
||||
ILogger<InMemoryPolicyEvaluationCache> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_cache = new ConcurrentDictionary<string, CacheItem>(StringComparer.Ordinal);
|
||||
|
||||
var cacheOptions = options?.Value.EvaluationCache ?? new PolicyEvaluationCacheOptions();
|
||||
_defaultTtl = TimeSpan.FromMinutes(cacheOptions.DefaultTtlMinutes);
|
||||
_maxItems = cacheOptions.MaxItems;
|
||||
}
|
||||
|
||||
public Task<PolicyEvaluationCacheResult> 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<PolicyEvaluationCacheBatch> GetBatchAsync(
|
||||
IReadOnlyList<PolicyEvaluationCacheKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var found = new Dictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry>();
|
||||
var notFound = new List<PolicyEvaluationCacheKey>();
|
||||
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<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for policy evaluation cache.
|
||||
/// </summary>
|
||||
public sealed class PolicyEvaluationCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for cache entries in minutes.
|
||||
/// </summary>
|
||||
public int DefaultTtlMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of items in the in-memory cache.
|
||||
/// </summary>
|
||||
public int MaxItems { get; set; } = 50000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable Redis as a distributed cache layer.
|
||||
/// </summary>
|
||||
public bool EnableRedis { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Redis connection string.
|
||||
/// </summary>
|
||||
public string? RedisConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Redis key prefix for policy evaluations.
|
||||
/// </summary>
|
||||
public string RedisKeyPrefix { get; set; } = "stellaops:pe:";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use hybrid mode (in-memory + Redis).
|
||||
/// </summary>
|
||||
public bool HybridMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for Redis operations in milliseconds.
|
||||
/// </summary>
|
||||
public int RedisTimeoutMs { get; set; } = 100;
|
||||
}
|
||||
Reference in New Issue
Block a user