// ----------------------------------------------------------------------------- // 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 Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.SbomService.Models; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Text.Json; namespace StellaOps.SbomService.Services; /// /// Cache service for lineage hover card data. /// Implements a 5-minute TTL cache to achieve <150ms response times. /// internal interface ILineageHoverCache { /// /// Gets a cached hover card if available. /// Task GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default); /// /// Sets a hover card in cache. /// Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default); /// /// Invalidates cached hover cards for an artifact. /// Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default); } /// /// Cache options for lineage hover cards. /// public sealed record LineageHoverCacheOptions { /// /// Whether caching is enabled. /// public bool Enabled { get; init; } = true; /// /// Time-to-live for hover card cache entries. /// Default: 5 minutes. /// [Range(typeof(TimeSpan), "00:00:01", "1.00:00:00", ErrorMessage = "Hover cache TTL must be between 1 second and 1 day.")] public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(5); /// /// Key prefix for hover cache entries. /// [MinLength(1)] public string KeyPrefix { get; init; } = "lineage:hover"; } /// /// Implementation of using . /// Supports Valkey/Redis or any IDistributedCache implementation. /// internal sealed class DistributedLineageHoverCache : ILineageHoverCache { private readonly IDistributedCache _cache; private readonly LineageHoverCacheOptions _options; private readonly ILogger _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 options, ILogger logger) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _options = options?.Value ?? new LineageHoverCacheOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task 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(cached, JsonOptions); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to get hover card from cache"); return null; } } /// 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"); } } /// 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; } } /// /// In-memory implementation of for testing. /// internal sealed class InMemoryLineageHoverCache : ILineageHoverCache { private readonly Dictionary _cache = new(); private readonly LineageHoverCacheOptions _options; private readonly TimeProvider _timeProvider; private readonly object _lock = new(); public InMemoryLineageHoverCache(LineageHoverCacheOptions? options = null, TimeProvider? timeProvider = null) { _options = options ?? new LineageHoverCacheOptions(); _timeProvider = timeProvider ?? TimeProvider.System; } public Task GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default) { if (!_options.Enabled) { return Task.FromResult(null); } var key = BuildKey(fromDigest, toDigest, tenantId); lock (_lock) { if (_cache.TryGetValue(key, out var entry)) { if (entry.ExpiresAt > _timeProvider.GetUtcNow()) { return Task.FromResult(entry.Card); } _cache.Remove(key); } } return Task.FromResult(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 = _timeProvider.GetUtcNow().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; } }