481 lines
14 KiB
C#
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"]);
|
|
}
|
|
}
|