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

This commit is contained in:
StellaOps Bot
2025-11-28 00:45:16 +02:00
parent 3b96b2e3ea
commit 1c6730a1d2
95 changed files with 14504 additions and 463 deletions

View File

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