up
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Service for joining reachability facts with policy evaluation inputs.
|
||||
/// Provides efficient batch lookups with caching and metrics.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityFactsJoiningService
|
||||
{
|
||||
private readonly IReachabilityFactsStore _store;
|
||||
private readonly IReachabilityFactsOverlayCache _cache;
|
||||
private readonly ILogger<ReachabilityFactsJoiningService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReachabilityFactsJoiningService(
|
||||
IReachabilityFactsStore store,
|
||||
IReachabilityFactsOverlayCache cache,
|
||||
ILogger<ReachabilityFactsJoiningService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets reachability facts for a batch of component-advisory pairs.
|
||||
/// Uses cache-first strategy with store fallback.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="items">List of component-advisory pairs.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Batch result with facts and cache statistics.</returns>
|
||||
public async Task<ReachabilityFactsBatch> GetFactsBatchAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<ReachabilityFactsRequest> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
|
||||
"reachability_facts.batch_lookup",
|
||||
ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("batch_size", items.Count);
|
||||
|
||||
var keys = items
|
||||
.Select(i => new ReachabilityFactKey(tenantId, i.ComponentPurl, i.AdvisoryId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// Try cache first
|
||||
var cacheResult = await _cache.GetBatchAsync(keys, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ReachabilityFactsTelemetry.RecordCacheHits(cacheResult.CacheHits);
|
||||
ReachabilityFactsTelemetry.RecordCacheMisses(cacheResult.CacheMisses);
|
||||
|
||||
activity?.SetTag("cache_hits", cacheResult.CacheHits);
|
||||
activity?.SetTag("cache_misses", cacheResult.CacheMisses);
|
||||
|
||||
if (cacheResult.NotFound.Count == 0)
|
||||
{
|
||||
// All items found in cache
|
||||
return cacheResult;
|
||||
}
|
||||
|
||||
// Fetch missing items from store
|
||||
var storeResults = await _store.GetBatchAsync(cacheResult.NotFound, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("store_hits", storeResults.Count);
|
||||
|
||||
// Populate cache with store results
|
||||
if (storeResults.Count > 0)
|
||||
{
|
||||
await _cache.SetBatchAsync(storeResults, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Merge results
|
||||
var allFound = new Dictionary<ReachabilityFactKey, ReachabilityFact>(cacheResult.Found);
|
||||
foreach (var (key, fact) in storeResults)
|
||||
{
|
||||
allFound[key] = fact;
|
||||
}
|
||||
|
||||
var stillNotFound = cacheResult.NotFound
|
||||
.Where(k => !storeResults.ContainsKey(k))
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Reachability facts lookup: {Total} requested, {CacheHits} cache hits, {StoreFetched} from store, {NotFound} not found",
|
||||
keys.Count,
|
||||
cacheResult.CacheHits,
|
||||
storeResults.Count,
|
||||
stillNotFound.Count);
|
||||
|
||||
return new ReachabilityFactsBatch
|
||||
{
|
||||
Found = allFound,
|
||||
NotFound = stillNotFound,
|
||||
CacheHits = cacheResult.CacheHits,
|
||||
CacheMisses = cacheResult.CacheMisses,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single reachability fact.
|
||||
/// </summary>
|
||||
public async Task<ReachabilityFact?> GetFactAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
|
||||
|
||||
// Try cache first
|
||||
var (cached, cacheHit) = await _cache.GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (cacheHit)
|
||||
{
|
||||
ReachabilityFactsTelemetry.RecordCacheHits(1);
|
||||
return cached;
|
||||
}
|
||||
|
||||
ReachabilityFactsTelemetry.RecordCacheMisses(1);
|
||||
|
||||
// Fall back to store
|
||||
var fact = await _store.GetAsync(tenantId, componentPurl, advisoryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (fact != null)
|
||||
{
|
||||
await _cache.SetAsync(key, fact, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return fact;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches signal context with reachability facts.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="componentPurl">Component PURL.</param>
|
||||
/// <param name="advisoryId">Advisory ID.</param>
|
||||
/// <param name="signals">Signal context to enrich.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if reachability fact was found and applied.</returns>
|
||||
public async Task<bool> EnrichSignalsAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
IDictionary<string, object?> signals,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fact = await GetFactAsync(tenantId, componentPurl, advisoryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (fact == null)
|
||||
{
|
||||
// Set default unknown state
|
||||
signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["state"] = "unknown",
|
||||
["confidence"] = 0m,
|
||||
["score"] = 0m,
|
||||
["has_runtime_evidence"] = false,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["state"] = fact.State.ToString().ToLowerInvariant(),
|
||||
["confidence"] = fact.Confidence,
|
||||
["score"] = fact.Score,
|
||||
["has_runtime_evidence"] = fact.HasRuntimeEvidence,
|
||||
["source"] = fact.Source,
|
||||
["method"] = fact.Method.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
ReachabilityFactsTelemetry.RecordFactApplied(fact.State.ToString().ToLowerInvariant());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a new reachability fact and updates the cache.
|
||||
/// </summary>
|
||||
public async Task SaveFactAsync(
|
||||
ReachabilityFact fact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _store.SaveAsync(fact, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
|
||||
await _cache.SetAsync(key, fact, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Saved reachability fact: {TenantId}/{ComponentPurl}/{AdvisoryId} = {State} ({Confidence:P0})",
|
||||
fact.TenantId,
|
||||
fact.ComponentPurl,
|
||||
fact.AdvisoryId,
|
||||
fact.State,
|
||||
fact.Confidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cache entries when reachability facts are updated externally.
|
||||
/// </summary>
|
||||
public Task InvalidateCacheAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
|
||||
return _cache.InvalidateAsync(key, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache statistics.
|
||||
/// </summary>
|
||||
public ReachabilityFactsCacheStats GetCacheStats() => _cache.GetStats();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request item for batch reachability facts lookup.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFactsRequest(string ComponentPurl, string AdvisoryId);
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry for reachability facts operations.
|
||||
/// Delegates to PolicyEngineTelemetry for centralized metrics.
|
||||
/// </summary>
|
||||
public static class ReachabilityFactsTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Records cache hits.
|
||||
/// </summary>
|
||||
public static void RecordCacheHits(int count)
|
||||
{
|
||||
PolicyEngineTelemetry.RecordReachabilityCacheHits(count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records cache misses.
|
||||
/// </summary>
|
||||
public static void RecordCacheMisses(int count)
|
||||
{
|
||||
PolicyEngineTelemetry.RecordReachabilityCacheMisses(count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a reachability fact being applied.
|
||||
/// </summary>
|
||||
public static void RecordFactApplied(string state)
|
||||
{
|
||||
PolicyEngineTelemetry.RecordReachabilityApplied(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current cache hit ratio from stats.
|
||||
/// </summary>
|
||||
public static double GetCacheHitRatio(ReachabilityFactsCacheStats stats)
|
||||
{
|
||||
return stats.HitRatio;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a reachability fact for a component-vulnerability pair.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFact
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this reachability fact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL this fact applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component_purl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability/advisory identifier (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("advisory_id")]
|
||||
public required string AdvisoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state (reachable, unreachable, unknown, under_investigation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required ReachabilityState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability score (0.0 to 1.0, higher = more reachable).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public decimal Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this fact has runtime evidence backing it.
|
||||
/// </summary>
|
||||
[JsonPropertyName("has_runtime_evidence")]
|
||||
public bool HasRuntimeEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the reachability analysis.
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Analysis method used (static, dynamic, hybrid).
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public required AnalysisMethod Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the call graph or evidence artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_ref")]
|
||||
public string? EvidenceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash of the analysis evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_hash")]
|
||||
public string? EvidenceHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this fact was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this fact expires and should be recomputed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state enumeration aligned with VEX status semantics.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ReachabilityState>))]
|
||||
public enum ReachabilityState
|
||||
{
|
||||
/// <summary>
|
||||
/// The vulnerable code path is reachable from application entry points.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachable")]
|
||||
Reachable,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerable code path is not reachable from application entry points.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unreachable")]
|
||||
Unreachable,
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status is unknown or could not be determined.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unknown")]
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Reachability is under investigation and may change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("under_investigation")]
|
||||
UnderInvestigation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analysis method enumeration.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AnalysisMethod>))]
|
||||
public enum AnalysisMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Static analysis (call graph, data flow).
|
||||
/// </summary>
|
||||
[JsonPropertyName("static")]
|
||||
Static,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic analysis (runtime profiling, instrumentation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("dynamic")]
|
||||
Dynamic,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid approach combining static and dynamic analysis.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hybrid")]
|
||||
Hybrid,
|
||||
|
||||
/// <summary>
|
||||
/// Manual assessment or expert judgment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manual")]
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for fetching reachability facts.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFactsQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant identifier (required).
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURLs to query (optional filter).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? ComponentPurls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory IDs to query (optional filter).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AdvisoryIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by reachability states (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<ReachabilityState>? States { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold (optional).
|
||||
/// </summary>
|
||||
public decimal? MinConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include expired facts (default: false).
|
||||
/// </summary>
|
||||
public bool IncludeExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Skip count for pagination.
|
||||
/// </summary>
|
||||
public int Skip { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite key for caching reachability facts.
|
||||
/// </summary>
|
||||
public readonly record struct ReachabilityFactKey(string TenantId, string ComponentPurl, string AdvisoryId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a cache key string from this composite key.
|
||||
/// </summary>
|
||||
public string ToCacheKey() => $"rf:{TenantId}:{ComponentPurl}:{AdvisoryId}";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a cache key back into a composite key.
|
||||
/// </summary>
|
||||
public static ReachabilityFactKey? FromCacheKey(string key)
|
||||
{
|
||||
var parts = key.Split(':', 4);
|
||||
if (parts.Length < 4 || parts[0] != "rf")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReachabilityFactKey(parts[1], parts[2], parts[3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch lookup result for reachability facts.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFactsBatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Facts that were found.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Keys that were not found.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ReachabilityFactKey> NotFound { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of cache hits.
|
||||
/// </summary>
|
||||
public int CacheHits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of cache misses that required store lookup.
|
||||
/// </summary>
|
||||
public int CacheMisses { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the reachability facts overlay cache.
|
||||
/// Provides fast in-memory/Redis caching layer above the persistent store.
|
||||
/// </summary>
|
||||
public interface IReachabilityFactsOverlayCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a reachability fact from the cache.
|
||||
/// </summary>
|
||||
Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(
|
||||
ReachabilityFactKey key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple reachability facts from the cache.
|
||||
/// </summary>
|
||||
Task<ReachabilityFactsBatch> GetBatchAsync(
|
||||
IReadOnlyList<ReachabilityFactKey> keys,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a reachability fact in the cache.
|
||||
/// </summary>
|
||||
Task SetAsync(
|
||||
ReachabilityFactKey key,
|
||||
ReachabilityFact fact,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets multiple reachability facts in the cache.
|
||||
/// </summary>
|
||||
Task SetBatchAsync(
|
||||
IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates a cache entry.
|
||||
/// </summary>
|
||||
Task InvalidateAsync(
|
||||
ReachabilityFactKey key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all cache entries for a tenant.
|
||||
/// </summary>
|
||||
Task InvalidateTenantAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache statistics.
|
||||
/// </summary>
|
||||
ReachabilityFactsCacheStats GetStats();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFactsCacheStats
|
||||
{
|
||||
public long TotalRequests { get; init; }
|
||||
public long CacheHits { get; init; }
|
||||
public long CacheMisses { get; init; }
|
||||
public double HitRatio => TotalRequests > 0 ? (double)CacheHits / TotalRequests : 0;
|
||||
public long ItemCount { get; init; }
|
||||
public long EvictionCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the reachability facts overlay cache.
|
||||
/// Uses a time-based eviction strategy with configurable TTL.
|
||||
/// </summary>
|
||||
public sealed class InMemoryReachabilityFactsOverlayCache : IReachabilityFactsOverlayCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InMemoryReachabilityFactsOverlayCache> _logger;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
private readonly int _maxItems;
|
||||
|
||||
private long _totalRequests;
|
||||
private long _cacheHits;
|
||||
private long _cacheMisses;
|
||||
private long _evictionCount;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public InMemoryReachabilityFactsOverlayCache(
|
||||
ILogger<InMemoryReachabilityFactsOverlayCache> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_cache = new ConcurrentDictionary<string, CacheEntry>(StringComparer.Ordinal);
|
||||
|
||||
var cacheOptions = options?.Value.ReachabilityCache ?? new ReachabilityFactsCacheOptions();
|
||||
_defaultTtl = TimeSpan.FromMinutes(cacheOptions.DefaultTtlMinutes);
|
||||
_maxItems = cacheOptions.MaxItems;
|
||||
}
|
||||
|
||||
public Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(
|
||||
ReachabilityFactKey key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _totalRequests);
|
||||
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
return Task.FromResult<(ReachabilityFact?, bool)>((entry.Fact, true));
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
|
||||
// Remove expired entry if present
|
||||
if (entry != null)
|
||||
{
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult<(ReachabilityFact?, bool)>((null, false));
|
||||
}
|
||||
|
||||
public async Task<ReachabilityFactsBatch> GetBatchAsync(
|
||||
IReadOnlyList<ReachabilityFactKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
var notFound = new List<ReachabilityFactKey>();
|
||||
var cacheHits = 0;
|
||||
var cacheMisses = 0;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var (fact, hit) = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (fact != null)
|
||||
{
|
||||
found[key] = fact;
|
||||
cacheHits++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(key);
|
||||
cacheMisses++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityFactsBatch
|
||||
{
|
||||
Found = found,
|
||||
NotFound = notFound,
|
||||
CacheHits = cacheHits,
|
||||
CacheMisses = cacheMisses,
|
||||
};
|
||||
}
|
||||
|
||||
public Task SetAsync(
|
||||
ReachabilityFactKey key,
|
||||
ReachabilityFact fact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureCapacity();
|
||||
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var ttl = fact.ExpiresAt.HasValue && fact.ExpiresAt.Value > now
|
||||
? fact.ExpiresAt.Value - now
|
||||
: _defaultTtl;
|
||||
|
||||
var entry = new CacheEntry(fact, now.Add(ttl));
|
||||
_cache[cacheKey] = entry;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetBatchAsync(
|
||||
IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureCapacity(facts.Count);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var (key, fact) in facts)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var ttl = fact.ExpiresAt.HasValue && fact.ExpiresAt.Value > now
|
||||
? fact.ExpiresAt.Value - now
|
||||
: _defaultTtl;
|
||||
|
||||
var entry = new CacheEntry(fact, now.Add(ttl));
|
||||
_cache[cacheKey] = entry;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateTenantAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prefix = $"rf:{tenantId}:";
|
||||
var keysToRemove = _cache.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Invalidated {Count} cache entries for tenant {TenantId}", keysToRemove.Count, tenantId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ReachabilityFactsCacheStats GetStats()
|
||||
{
|
||||
return new ReachabilityFactsCacheStats
|
||||
{
|
||||
TotalRequests = Interlocked.Read(ref _totalRequests),
|
||||
CacheHits = Interlocked.Read(ref _cacheHits),
|
||||
CacheMisses = Interlocked.Read(ref _cacheMisses),
|
||||
ItemCount = _cache.Count,
|
||||
EvictionCount = Interlocked.Read(ref _evictionCount),
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsureCapacity(int additionalItems = 1)
|
||||
{
|
||||
if (_cache.Count + additionalItems <= _maxItems)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var itemsToRemove = _cache.Count + additionalItems - _maxItems + (_maxItems / 10); // Remove 10% extra
|
||||
|
||||
// First, remove expired items
|
||||
var expiredKeys = _cache
|
||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
if (_cache.TryRemove(key, out _))
|
||||
{
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
itemsToRemove--;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsToRemove <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Then, remove oldest items by expiration time
|
||||
var oldestKeys = _cache
|
||||
.OrderBy(kvp => kvp.Value.ExpiresAt)
|
||||
.Take(itemsToRemove)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in oldestKeys)
|
||||
{
|
||||
if (_cache.TryRemove(key, out _))
|
||||
{
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Evicted {EvictedCount} cache entries (expired: {ExpiredCount}, oldest: {OldestCount})",
|
||||
expiredKeys.Count + oldestKeys.Count,
|
||||
expiredKeys.Count,
|
||||
oldestKeys.Count);
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(ReachabilityFact Fact, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the reachability facts cache.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityFactsCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for cache entries in minutes.
|
||||
/// </summary>
|
||||
public int DefaultTtlMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of items in the cache.
|
||||
/// </summary>
|
||||
public int MaxItems { get; set; } = 100000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable Redis as a distributed cache layer.
|
||||
/// </summary>
|
||||
public bool EnableRedis { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Redis connection string.
|
||||
/// </summary>
|
||||
public string? RedisConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Redis key prefix for reachability facts.
|
||||
/// </summary>
|
||||
public string RedisKeyPrefix { get; set; } = "stellaops:rf:";
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for reachability facts persistence.
|
||||
/// </summary>
|
||||
public interface IReachabilityFactsStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a single reachability fact by key.
|
||||
/// </summary>
|
||||
Task<ReachabilityFact?> GetAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple reachability facts by keys.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(
|
||||
IReadOnlyList<ReachabilityFactKey> keys,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries reachability facts with filtering.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReachabilityFact>> QueryAsync(
|
||||
ReachabilityFactsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves or updates a reachability fact.
|
||||
/// </summary>
|
||||
Task SaveAsync(
|
||||
ReachabilityFact fact,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves multiple reachability facts.
|
||||
/// </summary>
|
||||
Task SaveBatchAsync(
|
||||
IReadOnlyList<ReachabilityFact> facts,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a reachability fact.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of facts for a tenant.
|
||||
/// </summary>
|
||||
Task<long> CountAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the reachability facts store for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryReachabilityFactsStore : IReachabilityFactsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<ReachabilityFactKey, ReachabilityFact> _facts = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryReachabilityFactsStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ReachabilityFact?> GetAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
|
||||
_facts.TryGetValue(key, out var fact);
|
||||
return Task.FromResult(fact);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(
|
||||
IReadOnlyList<ReachabilityFactKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (_facts.TryGetValue(key, out var fact))
|
||||
{
|
||||
result[key] = fact;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>>(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFact>> QueryAsync(
|
||||
ReachabilityFactsQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var results = _facts.Values
|
||||
.Where(f => f.TenantId == query.TenantId)
|
||||
.Where(f => query.ComponentPurls == null || query.ComponentPurls.Contains(f.ComponentPurl))
|
||||
.Where(f => query.AdvisoryIds == null || query.AdvisoryIds.Contains(f.AdvisoryId))
|
||||
.Where(f => query.States == null || query.States.Contains(f.State))
|
||||
.Where(f => !query.MinConfidence.HasValue || f.Confidence >= query.MinConfidence.Value)
|
||||
.Where(f => query.IncludeExpired || !f.ExpiresAt.HasValue || f.ExpiresAt > now)
|
||||
.OrderByDescending(f => f.ComputedAt)
|
||||
.Skip(query.Skip)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReachabilityFact>>(results);
|
||||
}
|
||||
|
||||
public Task SaveAsync(ReachabilityFact fact, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
|
||||
_facts[key] = fact;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveBatchAsync(IReadOnlyList<ReachabilityFact> facts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var fact in facts)
|
||||
{
|
||||
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
|
||||
_facts[key] = fact;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
|
||||
_facts.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<long> CountAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = _facts.Values.Count(f => f.TenantId == tenantId);
|
||||
return Task.FromResult((long)count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index definitions for MongoDB reachability_facts collection.
|
||||
/// </summary>
|
||||
public static class ReachabilityFactsIndexes
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary compound index for efficient lookups.
|
||||
/// </summary>
|
||||
public const string PrimaryIndex = "tenant_component_advisory";
|
||||
|
||||
/// <summary>
|
||||
/// Index for querying by tenant and state.
|
||||
/// </summary>
|
||||
public const string TenantStateIndex = "tenant_state_computed";
|
||||
|
||||
/// <summary>
|
||||
/// Index for TTL expiration.
|
||||
/// </summary>
|
||||
public const string ExpirationIndex = "expires_at_ttl";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the index definitions for creating MongoDB indexes.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ReachabilityIndexDefinition> GetIndexDefinitions()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new ReachabilityIndexDefinition(
|
||||
PrimaryIndex,
|
||||
new[] { "tenant_id", "component_purl", "advisory_id" },
|
||||
Unique: true),
|
||||
new ReachabilityIndexDefinition(
|
||||
TenantStateIndex,
|
||||
new[] { "tenant_id", "state", "computed_at" },
|
||||
Unique: false),
|
||||
new ReachabilityIndexDefinition(
|
||||
ExpirationIndex,
|
||||
new[] { "expires_at" },
|
||||
Unique: false,
|
||||
ExpireAfterSeconds: 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index definition for MongoDB collection.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityIndexDefinition(
|
||||
string Name,
|
||||
IReadOnlyList<string> Fields,
|
||||
bool Unique,
|
||||
int? ExpireAfterSeconds = null);
|
||||
Reference in New Issue
Block a user