using System.Diagnostics; using StellaOps.VexLens.Api; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; namespace StellaOps.VexLens.Caching; /// /// Cache interface for consensus rationale storage. /// Used by Advisory AI for efficient rationale retrieval. /// public interface IConsensusRationaleCache { /// /// Gets a cached rationale by key. /// Task GetAsync( string cacheKey, CancellationToken cancellationToken = default); /// /// Sets a rationale in the cache. /// Task SetAsync( string cacheKey, DetailedConsensusRationale rationale, CacheOptions? options = null, CancellationToken cancellationToken = default); /// /// Gets or creates a rationale using the factory if not cached. /// Task GetOrCreateAsync( string cacheKey, Func> factory, CacheOptions? options = null, CancellationToken cancellationToken = default); /// /// Removes a rationale from the cache. /// Task RemoveAsync( string cacheKey, CancellationToken cancellationToken = default); /// /// Removes all rationales for a vulnerability-product pair. /// Task InvalidateAsync( string vulnerabilityId, string productKey, CancellationToken cancellationToken = default); /// /// Clears all cached rationales. /// Task ClearAsync(CancellationToken cancellationToken = default); /// /// Gets cache statistics. /// Task GetStatisticsAsync(CancellationToken cancellationToken = default); } /// /// Options for cache entries. /// public sealed record CacheOptions( /// /// Absolute expiration time. /// DateTimeOffset? AbsoluteExpiration = null, /// /// Sliding expiration duration. /// TimeSpan? SlidingExpiration = null, /// /// Cache entry priority. /// CachePriority Priority = CachePriority.Normal, /// /// Tags for grouping cache entries. /// IReadOnlyList? Tags = null); /// /// Cache entry priority. /// public enum CachePriority { Low, Normal, High, NeverRemove } /// /// Cache statistics. /// public sealed record CacheStatistics( /// /// Total number of cached entries. /// int EntryCount, /// /// Total cache hits. /// long HitCount, /// /// Total cache misses. /// long MissCount, /// /// Estimated memory usage in bytes. /// long EstimatedMemoryBytes, /// /// Hit rate percentage. /// double HitRate, /// /// When the cache was last cleared. /// DateTimeOffset? LastCleared); /// /// In-memory implementation of consensus rationale cache. /// public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache { private readonly Dictionary _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 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(null); } entry.LastAccessed = _timeProvider.GetUtcNow(); Interlocked.Increment(ref _hitCount); return Task.FromResult(entry.Rationale); } Interlocked.Increment(ref _missCount); return Task.FromResult(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 GetOrCreateAsync( string cacheKey, Func> 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 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; } } } /// /// Cached consensus rationale service that wraps the base service with caching. /// 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 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 GenerateBatchRationaleAsync( BatchRationaleRequest request, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var responses = new List(); var errors = new List(); 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 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}"; } } /// /// Event arguments for cache invalidation. /// public sealed record CacheInvalidationEvent( string VulnerabilityId, string ProductKey, string? TenantId, string Reason, DateTimeOffset OccurredAt); /// /// Interface for observing cache invalidations. /// public interface ICacheInvalidationObserver { /// /// Called when cache entries are invalidated. /// Task OnInvalidationAsync( CacheInvalidationEvent invalidation, CancellationToken cancellationToken = default); } /// /// Extension methods for cache configuration. /// public static class ConsensusCacheExtensions { /// /// Creates a cache key for a vulnerability-product pair. /// 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}"; } /// /// Creates default cache options for Advisory AI usage. /// public static CacheOptions CreateAdvisoryAiOptions( TimeSpan? slidingExpiration = null, CachePriority priority = CachePriority.High) { return new CacheOptions( SlidingExpiration: slidingExpiration ?? TimeSpan.FromHours(1), Priority: priority, Tags: ["advisory-ai"]); } }