Files
git.stella-ops.org/src/VexLens/StellaOps.VexLens/Caching/IConsensusRationaleCache.cs
2026-01-13 18:53:39 +02:00

481 lines
14 KiB
C#

using System.Diagnostics;
using StellaOps.VexLens.Api;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Caching;
/// <summary>
/// Cache interface for consensus rationale storage.
/// Used by Advisory AI for efficient rationale retrieval.
/// </summary>
public interface IConsensusRationaleCache
{
/// <summary>
/// Gets a cached rationale by key.
/// </summary>
Task<DetailedConsensusRationale?> GetAsync(
string cacheKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets a rationale in the cache.
/// </summary>
Task SetAsync(
string cacheKey,
DetailedConsensusRationale rationale,
CacheOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets or creates a rationale using the factory if not cached.
/// </summary>
Task<DetailedConsensusRationale> GetOrCreateAsync(
string cacheKey,
Func<CancellationToken, Task<DetailedConsensusRationale>> factory,
CacheOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes a rationale from the cache.
/// </summary>
Task RemoveAsync(
string cacheKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes all rationales for a vulnerability-product pair.
/// </summary>
Task InvalidateAsync(
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Clears all cached rationales.
/// </summary>
Task ClearAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
Task<CacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for cache entries.
/// </summary>
public sealed record CacheOptions(
/// <summary>
/// Absolute expiration time.
/// </summary>
DateTimeOffset? AbsoluteExpiration = null,
/// <summary>
/// Sliding expiration duration.
/// </summary>
TimeSpan? SlidingExpiration = null,
/// <summary>
/// Cache entry priority.
/// </summary>
CachePriority Priority = CachePriority.Normal,
/// <summary>
/// Tags for grouping cache entries.
/// </summary>
IReadOnlyList<string>? Tags = null);
/// <summary>
/// Cache entry priority.
/// </summary>
public enum CachePriority
{
Low,
Normal,
High,
NeverRemove
}
/// <summary>
/// Cache statistics.
/// </summary>
public sealed record CacheStatistics(
/// <summary>
/// Total number of cached entries.
/// </summary>
int EntryCount,
/// <summary>
/// Total cache hits.
/// </summary>
long HitCount,
/// <summary>
/// Total cache misses.
/// </summary>
long MissCount,
/// <summary>
/// Estimated memory usage in bytes.
/// </summary>
long EstimatedMemoryBytes,
/// <summary>
/// Hit rate percentage.
/// </summary>
double HitRate,
/// <summary>
/// When the cache was last cleared.
/// </summary>
DateTimeOffset? LastCleared);
/// <summary>
/// In-memory implementation of consensus rationale cache.
/// </summary>
public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
{
private readonly Dictionary<string, CacheEntry> _cache = new();
private readonly object _lock = new();
private readonly int _maxEntries;
private readonly TimeProvider _timeProvider;
private long _hitCount;
private long _missCount;
private DateTimeOffset? _lastCleared;
public InMemoryConsensusRationaleCache(int maxEntries = 10000, TimeProvider? timeProvider = null)
{
_maxEntries = maxEntries;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<DetailedConsensusRationale?> GetAsync(
string cacheKey,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_cache.TryGetValue(cacheKey, out var entry))
{
if (IsExpired(entry))
{
_cache.Remove(cacheKey);
Interlocked.Increment(ref _missCount);
return Task.FromResult<DetailedConsensusRationale?>(null);
}
entry.LastAccessed = _timeProvider.GetUtcNow();
Interlocked.Increment(ref _hitCount);
return Task.FromResult<DetailedConsensusRationale?>(entry.Rationale);
}
Interlocked.Increment(ref _missCount);
return Task.FromResult<DetailedConsensusRationale?>(null);
}
}
public Task SetAsync(
string cacheKey,
DetailedConsensusRationale rationale,
CacheOptions? options = null,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
// Evict if at capacity
if (_cache.Count >= _maxEntries && !_cache.ContainsKey(cacheKey))
{
EvictOldestEntry();
}
var now = _timeProvider.GetUtcNow();
_cache[cacheKey] = new CacheEntry
{
Rationale = rationale,
Options = options ?? new CacheOptions(),
Created = now,
LastAccessed = now
};
return Task.CompletedTask;
}
}
public async Task<DetailedConsensusRationale> GetOrCreateAsync(
string cacheKey,
Func<CancellationToken, Task<DetailedConsensusRationale>> factory,
CacheOptions? options = null,
CancellationToken cancellationToken = default)
{
var cached = await GetAsync(cacheKey, cancellationToken);
if (cached != null)
{
return cached;
}
var rationale = await factory(cancellationToken);
await SetAsync(cacheKey, rationale, options, cancellationToken);
return rationale;
}
public Task RemoveAsync(
string cacheKey,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_cache.Remove(cacheKey);
return Task.CompletedTask;
}
}
public Task InvalidateAsync(
string vulnerabilityId,
string productKey,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var keysToRemove = _cache
.Where(kvp => kvp.Value.Rationale.VulnerabilityId == vulnerabilityId &&
kvp.Value.Rationale.ProductKey == productKey)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
return Task.CompletedTask;
}
}
public Task ClearAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
_cache.Clear();
_lastCleared = _timeProvider.GetUtcNow();
return Task.CompletedTask;
}
}
public Task<CacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var hits = Interlocked.Read(ref _hitCount);
var misses = Interlocked.Read(ref _missCount);
var total = hits + misses;
return Task.FromResult(new CacheStatistics(
EntryCount: _cache.Count,
HitCount: hits,
MissCount: misses,
EstimatedMemoryBytes: EstimateMemoryUsage(),
HitRate: total > 0 ? (double)hits / total : 0,
LastCleared: _lastCleared));
}
}
private bool IsExpired(CacheEntry entry)
{
var now = _timeProvider.GetUtcNow();
if (entry.Options.AbsoluteExpiration.HasValue &&
now >= entry.Options.AbsoluteExpiration.Value)
{
return true;
}
if (entry.Options.SlidingExpiration.HasValue &&
now - entry.LastAccessed >= entry.Options.SlidingExpiration.Value)
{
return true;
}
return false;
}
private void EvictOldestEntry()
{
var oldest = _cache
.Where(kvp => kvp.Value.Options.Priority != CachePriority.NeverRemove)
.OrderBy(kvp => kvp.Value.Options.Priority)
.ThenBy(kvp => kvp.Value.LastAccessed)
.FirstOrDefault();
if (oldest.Key != null)
{
_cache.Remove(oldest.Key);
}
}
private long EstimateMemoryUsage()
{
// Rough estimate: 1KB per entry on average
return _cache.Count * 1024L;
}
private sealed class CacheEntry
{
public required DetailedConsensusRationale Rationale { get; init; }
public required CacheOptions Options { get; init; }
public required DateTimeOffset Created { get; init; }
public DateTimeOffset LastAccessed { get; set; }
}
}
/// <summary>
/// Cached consensus rationale service that wraps the base service with caching.
/// </summary>
public sealed class CachedConsensusRationaleService : IConsensusRationaleService
{
private readonly IConsensusRationaleService _inner;
private readonly IConsensusRationaleCache _cache;
private readonly CacheOptions _defaultOptions;
public CachedConsensusRationaleService(
IConsensusRationaleService inner,
IConsensusRationaleCache cache,
CacheOptions? defaultOptions = null)
{
_inner = inner;
_cache = cache;
_defaultOptions = defaultOptions ?? new CacheOptions(
SlidingExpiration: TimeSpan.FromMinutes(30));
}
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
GenerateRationaleRequest request,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(request);
var stopwatch = Stopwatch.StartNew();
var rationale = await _cache.GetOrCreateAsync(
cacheKey,
async ct =>
{
var response = await _inner.GenerateRationaleAsync(request, ct);
return response.Rationale;
},
_defaultOptions,
cancellationToken);
var elapsedMs = stopwatch.Elapsed.TotalMilliseconds;
return new GenerateRationaleResponse(
Rationale: rationale,
Stats: new RationaleGenerationStats(
StatementsAnalyzed: 0, // Not tracked in cache hit
IssuersInvolved: 0,
ConflictsDetected: 0,
FactorsIdentified: rationale.DecisionFactors.Count,
GenerationTimeMs: elapsedMs));
}
public async Task<BatchRationaleResponse> GenerateBatchRationaleAsync(
BatchRationaleRequest request,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var responses = new List<GenerateRationaleResponse>();
var errors = new List<RationaleError>();
foreach (var req in request.Requests)
{
try
{
var response = await GenerateRationaleAsync(req, cancellationToken);
responses.Add(response);
}
catch (Exception ex)
{
errors.Add(new RationaleError(
VulnerabilityId: req.VulnerabilityId,
ProductKey: req.ProductKey,
Code: "GENERATION_FAILED",
Message: ex.Message));
}
}
return new BatchRationaleResponse(
Responses: responses,
Errors: errors,
TotalTimeMs: stopwatch.Elapsed.TotalMilliseconds);
}
public Task<DetailedConsensusRationale> GenerateFromResultAsync(
VexConsensusResult result,
string explanationFormat = "human",
CancellationToken cancellationToken = default)
{
// Direct passthrough - results are ephemeral and shouldn't be cached
return _inner.GenerateFromResultAsync(result, explanationFormat, cancellationToken);
}
private static string BuildCacheKey(GenerateRationaleRequest request)
{
return $"rationale:{request.VulnerabilityId}:{request.ProductKey}:{request.TenantId ?? "default"}:{request.Verbosity}:{request.ExplanationFormat}";
}
}
/// <summary>
/// Event arguments for cache invalidation.
/// </summary>
public sealed record CacheInvalidationEvent(
string VulnerabilityId,
string ProductKey,
string? TenantId,
string Reason,
DateTimeOffset OccurredAt);
/// <summary>
/// Interface for observing cache invalidations.
/// </summary>
public interface ICacheInvalidationObserver
{
/// <summary>
/// Called when cache entries are invalidated.
/// </summary>
Task OnInvalidationAsync(
CacheInvalidationEvent invalidation,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extension methods for cache configuration.
/// </summary>
public static class ConsensusCacheExtensions
{
/// <summary>
/// Creates a cache key for a vulnerability-product pair.
/// </summary>
public static string CreateCacheKey(
string vulnerabilityId,
string productKey,
string? tenantId = null,
string verbosity = "standard",
string format = "human")
{
return $"rationale:{vulnerabilityId}:{productKey}:{tenantId ?? "default"}:{verbosity}:{format}";
}
/// <summary>
/// Creates default cache options for Advisory AI usage.
/// </summary>
public static CacheOptions CreateAdvisoryAiOptions(
TimeSpan? slidingExpiration = null,
CachePriority priority = CachePriority.High)
{
return new CacheOptions(
SlidingExpiration: slidingExpiration ?? TimeSpan.FromHours(1),
Priority: priority,
Tags: ["advisory-ai"]);
}
}