Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Services/LineageHoverCache.cs
2026-02-01 21:37:40 +02:00

316 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Cache service for lineage hover card data.
/// Implements a 5-minute TTL cache to achieve &lt;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>
[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);
/// <summary>
/// Key prefix for hover cache entries.
/// </summary>
[MinLength(1)]
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 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<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 > _timeProvider.GetUtcNow())
{
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 = _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;
}
}