Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageHoverCache.cs
|
||||
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-015)
|
||||
// Task: Add Valkey caching for hover card data with 5-minute TTL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cache service for lineage hover card data.
|
||||
/// Implements a 5-minute TTL cache to achieve <150ms response times.
|
||||
/// </summary>
|
||||
internal interface ILineageHoverCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a cached hover card if available.
|
||||
/// </summary>
|
||||
Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a hover card in cache.
|
||||
/// </summary>
|
||||
Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cached hover cards for an artifact.
|
||||
/// </summary>
|
||||
Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache options for lineage hover cards.
|
||||
/// </summary>
|
||||
public sealed record LineageHoverCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether caching is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for hover card cache entries.
|
||||
/// Default: 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for hover cache entries.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; init; } = "lineage:hover";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ILineageHoverCache"/> using <see cref="IDistributedCache"/>.
|
||||
/// Supports Valkey/Redis or any IDistributedCache implementation.
|
||||
/// </summary>
|
||||
internal sealed class DistributedLineageHoverCache : ILineageHoverCache
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly LineageHoverCacheOptions _options;
|
||||
private readonly ILogger<DistributedLineageHoverCache> _logger;
|
||||
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.LineageCache");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public DistributedLineageHoverCache(
|
||||
IDistributedCache cache,
|
||||
IOptions<LineageHoverCacheOptions> options,
|
||||
ILogger<DistributedLineageHoverCache> logger)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_options = options?.Value ?? new LineageHoverCacheOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SbomLineageHoverCard?> GetAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var activity = _activitySource.StartActivity("hover_cache.get");
|
||||
activity?.SetTag("from_digest", TruncateDigest(fromDigest));
|
||||
activity?.SetTag("to_digest", TruncateDigest(toDigest));
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var key = BuildKey(fromDigest, toDigest, tenantId);
|
||||
var cached = await _cache.GetStringAsync(key, ct).ConfigureAwait(false);
|
||||
|
||||
if (cached is null)
|
||||
{
|
||||
activity?.SetTag("cache_hit", false);
|
||||
_logger.LogDebug("Cache miss for hover card {From} -> {To}", TruncateDigest(fromDigest), TruncateDigest(toDigest));
|
||||
return null;
|
||||
}
|
||||
|
||||
activity?.SetTag("cache_hit", true);
|
||||
_logger.LogDebug("Cache hit for hover card {From} -> {To}", TruncateDigest(fromDigest), TruncateDigest(toDigest));
|
||||
|
||||
return JsonSerializer.Deserialize<SbomLineageHoverCard>(cached, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get hover card from cache");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SetAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
string tenantId,
|
||||
SbomLineageHoverCard hoverCard,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = _activitySource.StartActivity("hover_cache.set");
|
||||
activity?.SetTag("from_digest", TruncateDigest(fromDigest));
|
||||
activity?.SetTag("to_digest", TruncateDigest(toDigest));
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var key = BuildKey(fromDigest, toDigest, tenantId);
|
||||
var json = JsonSerializer.Serialize(hoverCard, JsonOptions);
|
||||
|
||||
var cacheOptions = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.Ttl
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(key, json, cacheOptions, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Cached hover card {From} -> {To} with TTL {Ttl}", TruncateDigest(fromDigest), TruncateDigest(toDigest), _options.Ttl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to set hover card in cache");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task InvalidateAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var activity = _activitySource.StartActivity("hover_cache.invalidate");
|
||||
activity?.SetTag("artifact_digest", TruncateDigest(artifactDigest));
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
|
||||
// Note: Full pattern-based invalidation requires Valkey SCAN.
|
||||
// For now, we rely on TTL expiration. Pattern invalidation can be added
|
||||
// when using direct Valkey client (StackExchange.Redis).
|
||||
_logger.LogDebug("Hover card invalidation requested for {Artifact} (relying on TTL)", TruncateDigest(artifactDigest));
|
||||
}
|
||||
|
||||
private string BuildKey(string fromDigest, string toDigest, string tenantId)
|
||||
{
|
||||
// Format: {prefix}:{tenant}:{from_short}:{to_short}
|
||||
var fromShort = GetDigestShort(fromDigest);
|
||||
var toShort = GetDigestShort(toDigest);
|
||||
return $"{_options.KeyPrefix}:{tenantId}:{fromShort}:{toShort}";
|
||||
}
|
||||
|
||||
private static string GetDigestShort(string digest)
|
||||
{
|
||||
// Extract first 16 chars after algorithm prefix for shorter key
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
|
||||
{
|
||||
return digest[(colonIndex + 1)..(colonIndex + 17)];
|
||||
}
|
||||
return digest.Length > 16 ? digest[..16] : digest;
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
|
||||
{
|
||||
return $"{digest[..(colonIndex + 13)]}...";
|
||||
}
|
||||
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ILineageHoverCache"/> for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryLineageHoverCache : ILineageHoverCache
|
||||
{
|
||||
private readonly Dictionary<string, (SbomLineageHoverCard Card, DateTimeOffset ExpiresAt)> _cache = new();
|
||||
private readonly LineageHoverCacheOptions _options;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public InMemoryLineageHoverCache(LineageHoverCacheOptions? options = null)
|
||||
{
|
||||
_options = options ?? new LineageHoverCacheOptions();
|
||||
}
|
||||
|
||||
public Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult<SbomLineageHoverCard?>(null);
|
||||
}
|
||||
|
||||
var key = BuildKey(fromDigest, toDigest, tenantId);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Task.FromResult<SbomLineageHoverCard?>(entry.Card);
|
||||
}
|
||||
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<SbomLineageHoverCard?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var key = BuildKey(fromDigest, toDigest, tenantId);
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(_options.Ttl);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_cache[key] = (hoverCard, expiresAt);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
var prefix = $"{_options.KeyPrefix}:{tenantId}:";
|
||||
var digestShort = GetDigestShort(artifactDigest);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var keysToRemove = _cache.Keys
|
||||
.Where(k => k.StartsWith(prefix, StringComparison.Ordinal) && k.Contains(digestShort))
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string BuildKey(string fromDigest, string toDigest, string tenantId)
|
||||
{
|
||||
var fromShort = GetDigestShort(fromDigest);
|
||||
var toShort = GetDigestShort(toDigest);
|
||||
return $"{_options.KeyPrefix}:{tenantId}:{fromShort}:{toShort}";
|
||||
}
|
||||
|
||||
private static string GetDigestShort(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
|
||||
{
|
||||
return digest[(colonIndex + 1)..(colonIndex + 17)];
|
||||
}
|
||||
return digest.Length > 16 ? digest[..16] : digest;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user