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