// ----------------------------------------------------------------------------- // CachingVexObservationProvider.cs // Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service // Description: Caching wrapper for VEX observation provider with batch prefetch. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace StellaOps.Scanner.Gate; /// /// Caching wrapper for that supports batch prefetch. /// Implements short TTL bounded cache for gate throughput optimization. /// public sealed class CachingVexObservationProvider : IVexObservationBatchProvider, IDisposable { private readonly IVexObservationQuery _query; private readonly string _tenantId; private readonly MemoryCache _cache; private readonly TimeSpan _cacheTtl; private readonly ILogger _logger; private readonly SemaphoreSlim _prefetchLock = new(1, 1); /// /// Default cache size limit (number of entries). /// public const int DefaultCacheSizeLimit = 10_000; /// /// Default cache TTL. /// public static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromMinutes(5); public CachingVexObservationProvider( IVexObservationQuery query, string tenantId, ILogger logger, TimeSpan? cacheTtl = null, int? cacheSizeLimit = null) { _query = query; _tenantId = tenantId; _logger = logger; _cacheTtl = cacheTtl ?? DefaultCacheTtl; _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = cacheSizeLimit ?? DefaultCacheSizeLimit, }); } /// public async Task GetVexStatusAsync( string vulnerabilityId, string purl, CancellationToken cancellationToken = default) { var cacheKey = BuildCacheKey(vulnerabilityId, purl); if (_cache.TryGetValue(cacheKey, out VexObservationResult? cached)) { _logger.LogTrace("VEX cache hit: {VulnerabilityId} / {Purl}", vulnerabilityId, purl); return cached; } _logger.LogTrace("VEX cache miss: {VulnerabilityId} / {Purl}", vulnerabilityId, purl); var queryResult = await _query.GetEffectiveStatusAsync( _tenantId, vulnerabilityId, purl, cancellationToken); if (queryResult is null) { return null; } var result = MapToObservationResult(queryResult); CacheResult(cacheKey, result); return result; } /// public async Task> GetStatementsAsync( string vulnerabilityId, string purl, CancellationToken cancellationToken = default) { var statements = await _query.GetStatementsAsync( _tenantId, vulnerabilityId, purl, cancellationToken); return statements .Select(s => new VexStatementInfo { StatementId = s.StatementId, IssuerId = s.IssuerId, Status = s.Status, Timestamp = s.Timestamp, TrustWeight = s.TrustWeight, }) .ToList(); } /// public async Task PrefetchAsync( IReadOnlyList keys, CancellationToken cancellationToken = default) { if (keys.Count == 0) { return; } // Deduplicate and find keys not in cache var uncachedKeys = keys .DistinctBy(k => BuildCacheKey(k.VulnerabilityId, k.Purl)) .Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.Purl), out _)) .Select(k => new VexQueryKey(k.VulnerabilityId, k.Purl)) .ToList(); if (uncachedKeys.Count == 0) { _logger.LogDebug("Prefetch: all {Count} keys already cached", keys.Count); return; } _logger.LogDebug( "Prefetch: fetching {UncachedCount} of {TotalCount} keys", uncachedKeys.Count, keys.Count); await _prefetchLock.WaitAsync(cancellationToken); try { // Double-check after acquiring lock uncachedKeys = uncachedKeys .Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.ProductId), out _)) .ToList(); if (uncachedKeys.Count == 0) { return; } var batchResults = await _query.BatchLookupAsync( _tenantId, uncachedKeys, cancellationToken); foreach (var (key, result) in batchResults) { var cacheKey = BuildCacheKey(key.VulnerabilityId, key.ProductId); var observationResult = MapToObservationResult(result); CacheResult(cacheKey, observationResult); } _logger.LogDebug( "Prefetch: cached {ResultCount} results", batchResults.Count); } finally { _prefetchLock.Release(); } } /// /// Gets cache statistics. /// public CacheStatistics GetStatistics() => new() { CurrentEntryCount = _cache.Count, }; /// public void Dispose() { _cache.Dispose(); _prefetchLock.Dispose(); } private static string BuildCacheKey(string vulnerabilityId, string productId) => string.Format( System.Globalization.CultureInfo.InvariantCulture, "vex:{0}:{1}", vulnerabilityId.ToUpperInvariant(), productId.ToLowerInvariant()); private static VexObservationResult MapToObservationResult(VexObservationQueryResult queryResult) => new() { Status = queryResult.Status, Justification = queryResult.Justification, Confidence = queryResult.Confidence, BackportHints = queryResult.BackportHints, }; private void CacheResult(string cacheKey, VexObservationResult result) { var options = new MemoryCacheEntryOptions { Size = 1, SlidingExpiration = _cacheTtl, AbsoluteExpirationRelativeToNow = _cacheTtl * 2, }; _cache.Set(cacheKey, result, options); } } /// /// Cache statistics for monitoring. /// public sealed record CacheStatistics { /// /// Current number of entries in cache. /// public int CurrentEntryCount { get; init; } }