sprints and audit work
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Caching wrapper for <see cref="IVexObservationProvider"/> that supports batch prefetch.
|
||||
/// Implements short TTL bounded cache for gate throughput optimization.
|
||||
/// </summary>
|
||||
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<CachingVexObservationProvider> _logger;
|
||||
private readonly SemaphoreSlim _prefetchLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Default cache size limit (number of entries).
|
||||
/// </summary>
|
||||
public const int DefaultCacheSizeLimit = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Default cache TTL.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
public CachingVexObservationProvider(
|
||||
IVexObservationQuery query,
|
||||
string tenantId,
|
||||
ILogger<CachingVexObservationProvider> logger,
|
||||
TimeSpan? cacheTtl = null,
|
||||
int? cacheSizeLimit = null)
|
||||
{
|
||||
_query = query;
|
||||
_tenantId = tenantId;
|
||||
_logger = logger;
|
||||
_cacheTtl = cacheTtl ?? DefaultCacheTtl;
|
||||
|
||||
_cache = new MemoryCache(new MemoryCacheOptions
|
||||
{
|
||||
SizeLimit = cacheSizeLimit ?? DefaultCacheSizeLimit,
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VexObservationResult?> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VexStatementInfo>> 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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PrefetchAsync(
|
||||
IReadOnlyList<VexLookupKey> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache statistics.
|
||||
/// </summary>
|
||||
public CacheStatistics GetStatistics() => new()
|
||||
{
|
||||
CurrentEntryCount = _cache.Count,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics for monitoring.
|
||||
/// </summary>
|
||||
public sealed record CacheStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Current number of entries in cache.
|
||||
/// </summary>
|
||||
public int CurrentEntryCount { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user