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,143 @@
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Caching;
/// <summary>
/// Interface for policy evaluation result caching.
/// Supports deterministic caching with Redis and in-memory fallback.
/// </summary>
public interface IPolicyEvaluationCache
{
/// <summary>
/// Gets a cached evaluation result.
/// </summary>
Task<PolicyEvaluationCacheResult> GetAsync(
PolicyEvaluationCacheKey key,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple cached evaluation results.
/// </summary>
Task<PolicyEvaluationCacheBatch> GetBatchAsync(
IReadOnlyList<PolicyEvaluationCacheKey> keys,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets a cached evaluation result.
/// </summary>
Task SetAsync(
PolicyEvaluationCacheKey key,
PolicyEvaluationCacheEntry entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets multiple cached evaluation results.
/// </summary>
Task SetBatchAsync(
IReadOnlyDictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry> entries,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates a cached result.
/// </summary>
Task InvalidateAsync(
PolicyEvaluationCacheKey key,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all cached results for a policy digest.
/// </summary>
Task InvalidateByPolicyDigestAsync(
string policyDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
PolicyEvaluationCacheStats GetStats();
}
/// <summary>
/// Key for policy evaluation cache lookups.
/// </summary>
public sealed record PolicyEvaluationCacheKey(
string PolicyDigest,
string SubjectDigest,
string ContextDigest)
{
public string ToCacheKey() => $"pe:{PolicyDigest}:{SubjectDigest}:{ContextDigest}";
public static PolicyEvaluationCacheKey Create(
string policyDigest,
string subjectDigest,
string contextDigest)
{
return new PolicyEvaluationCacheKey(
policyDigest ?? throw new ArgumentNullException(nameof(policyDigest)),
subjectDigest ?? throw new ArgumentNullException(nameof(subjectDigest)),
contextDigest ?? throw new ArgumentNullException(nameof(contextDigest)));
}
}
/// <summary>
/// Cached evaluation entry.
/// </summary>
public sealed record PolicyEvaluationCacheEntry(
string Status,
string? Severity,
string? RuleName,
int? Priority,
ImmutableDictionary<string, string> Annotations,
ImmutableArray<string> Warnings,
string? ExceptionId,
string CorrelationId,
DateTimeOffset EvaluatedAt,
DateTimeOffset ExpiresAt);
/// <summary>
/// Result of a cache lookup.
/// </summary>
public sealed record PolicyEvaluationCacheResult(
PolicyEvaluationCacheEntry? Entry,
bool CacheHit,
CacheSource Source);
/// <summary>
/// Source of cached data.
/// </summary>
public enum CacheSource
{
None,
InMemory,
Redis
}
/// <summary>
/// Batch result of cache lookups.
/// </summary>
public sealed record PolicyEvaluationCacheBatch
{
public required IReadOnlyDictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry> Found { get; init; }
public required IReadOnlyList<PolicyEvaluationCacheKey> NotFound { get; init; }
public int CacheHits { get; init; }
public int CacheMisses { get; init; }
public int RedisHits { get; init; }
public int InMemoryHits { get; init; }
}
/// <summary>
/// Cache statistics.
/// </summary>
public sealed record PolicyEvaluationCacheStats
{
public long TotalRequests { get; init; }
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public long RedisHits { get; init; }
public long InMemoryHits { get; init; }
public long RedisFallbacks { get; init; }
public double HitRatio => TotalRequests > 0 ? (double)CacheHits / TotalRequests : 0;
public long ItemCount { get; init; }
public long EvictionCount { get; init; }
}

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