316 lines
11 KiB
C#
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 <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;
|
|
}
|
|
}
|