using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Messaging; using StellaOps.Messaging.Abstractions; using StellaOps.Policy.Engine.Options; using StellaOps.Policy.Engine.Telemetry; namespace StellaOps.Policy.Engine.EffectiveDecisionMap; /// /// Transport-agnostic effective decision map using StellaOps.Messaging abstractions. /// Works with any configured transport (Valkey, PostgreSQL, InMemory). /// internal sealed class MessagingEffectiveDecisionMap : IEffectiveDecisionMap { private readonly IDistributedCache _entryCache; private readonly ISortedIndex _assetIndex; private readonly IDistributedCache _versionCache; private readonly ILogger _logger; private readonly EffectiveDecisionMapOptions _options; private readonly TimeProvider _timeProvider; private const string EntryKeyPrefix = "edm:entry"; private const string IndexKeyPrefix = "edm:index"; private const string VersionKeyPrefix = "edm:version"; public MessagingEffectiveDecisionMap( IDistributedCacheFactory cacheFactory, ISortedIndexFactory sortedIndexFactory, ILogger logger, IOptions options, TimeProvider timeProvider) { ArgumentNullException.ThrowIfNull(cacheFactory); ArgumentNullException.ThrowIfNull(sortedIndexFactory); _entryCache = cacheFactory.Create(new CacheOptions { KeyPrefix = "edm:entries" }); _assetIndex = sortedIndexFactory.Create("edm-asset-index"); _versionCache = cacheFactory.Create(new CacheOptions { KeyPrefix = "edm:versions" }); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value.EffectiveDecisionMap ?? new EffectiveDecisionMapOptions(); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public async Task SetAsync( string tenantId, string snapshotId, EffectiveDecisionEntry entry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId); var indexKey = GetIndexKey(tenantId, snapshotId); var now = _timeProvider.GetUtcNow(); var ttl = entry.ExpiresAt - now; if (ttl <= TimeSpan.Zero) { ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes); } var cacheOptions = new CacheEntryOptions { TimeToLive = ttl }; // Store entry with TTL await _entryCache.SetAsync(entryKey, entry, cacheOptions, cancellationToken).ConfigureAwait(false); // Add to sorted index by evaluated_at timestamp var score = entry.EvaluatedAt.ToUnixTimeMilliseconds(); await _assetIndex.AddAsync(indexKey, entry.AssetId, score, cancellationToken).ConfigureAwait(false); // Set TTL on index (slightly longer than entries) await _assetIndex.SetExpirationAsync(indexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken).ConfigureAwait(false); PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1, new KeyValuePair("operation", "set"), new KeyValuePair("tenant_id", tenantId)); } public async Task SetBatchAsync( string tenantId, string snapshotId, IEnumerable entries, CancellationToken cancellationToken = default) { var now = _timeProvider.GetUtcNow(); var indexKey = GetIndexKey(tenantId, snapshotId); var count = 0; var maxTtl = TimeSpan.Zero; var indexElements = new List>(); foreach (var entry in entries) { var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId); var ttl = entry.ExpiresAt - now; if (ttl <= TimeSpan.Zero) { ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes); } if (ttl > maxTtl) maxTtl = ttl; var cacheOptions = new CacheEntryOptions { TimeToLive = ttl }; await _entryCache.SetAsync(entryKey, entry, cacheOptions, cancellationToken).ConfigureAwait(false); indexElements.Add(new ScoredElement(entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds())); count++; } if (indexElements.Count > 0) { await _assetIndex.AddRangeAsync(indexKey, indexElements, cancellationToken).ConfigureAwait(false); await _assetIndex.SetExpirationAsync(indexKey, maxTtl.Add(TimeSpan.FromMinutes(5)), cancellationToken).ConfigureAwait(false); } // Increment version after batch write await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false); PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count, new KeyValuePair("operation", "set_batch"), new KeyValuePair("tenant_id", tenantId)); _logger.LogDebug("Set {Count} effective decisions for snapshot {SnapshotId}", count, snapshotId); } public async Task GetAsync( string tenantId, string snapshotId, string assetId, CancellationToken cancellationToken = default) { var entryKey = GetEntryKey(tenantId, snapshotId, assetId); var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false); PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1, new KeyValuePair("operation", "get"), new KeyValuePair("tenant_id", tenantId), new KeyValuePair("cache_hit", result.HasValue)); return result.HasValue ? result.Value : null; } public async Task GetBatchAsync( string tenantId, string snapshotId, IReadOnlyList assetIds, CancellationToken cancellationToken = default) { var entries = new Dictionary(); var notFound = new List(); foreach (var assetId in assetIds) { var entryKey = GetEntryKey(tenantId, snapshotId, assetId); var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false); if (result.HasValue && result.Value is not null) { entries[assetId] = result.Value; } else { notFound.Add(assetId); } } var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false); PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Count, new KeyValuePair("operation", "get_batch"), new KeyValuePair("tenant_id", tenantId)); return new EffectiveDecisionQueryResult { Entries = entries, NotFound = notFound, MapVersion = version, FromCache = true, }; } public async Task> GetAllForSnapshotAsync( string tenantId, string snapshotId, EffectiveDecisionFilter? filter = null, CancellationToken cancellationToken = default) { var indexKey = GetIndexKey(tenantId, snapshotId); // Get all asset IDs from index, ordered by score (evaluated_at) descending var assetElements = await _assetIndex.GetByRankAsync( indexKey, 0, -1, SortOrder.Descending, cancellationToken).ConfigureAwait(false); if (assetElements.Count == 0) { return Array.Empty(); } var entries = new List(); foreach (var element in assetElements) { var entryKey = GetEntryKey(tenantId, snapshotId, element.Element); var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false); if (!result.HasValue || result.Value is null) continue; var entry = result.Value; // Apply filters if (filter != null) { if (filter.Statuses?.Count > 0 && !filter.Statuses.Contains(entry.Status, StringComparer.OrdinalIgnoreCase)) { continue; } if (filter.Severities?.Count > 0 && (entry.Severity is null || !filter.Severities.Contains(entry.Severity, StringComparer.OrdinalIgnoreCase))) { continue; } if (filter.HasException == true && entry.ExceptionId is null) { continue; } if (filter.HasException == false && entry.ExceptionId is not null) { continue; } if (filter.MinAdvisoryCount.HasValue && entry.AdvisoryCount < filter.MinAdvisoryCount) { continue; } if (filter.MinHighSeverityCount.HasValue && entry.HighSeverityCount < filter.MinHighSeverityCount) { continue; } } entries.Add(entry); // Apply limit (accounting for offset) if (filter?.Limit > 0 && entries.Count >= filter.Limit + (filter?.Offset ?? 0)) { break; } } // Apply offset if (filter?.Offset > 0) { entries = entries.Skip(filter.Offset).ToList(); } // Apply final limit if (filter?.Limit > 0) { entries = entries.Take(filter.Limit).ToList(); } PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1, new KeyValuePair("operation", "get_all"), new KeyValuePair("tenant_id", tenantId)); return entries; } public async Task GetSummaryAsync( string tenantId, string snapshotId, CancellationToken cancellationToken = default) { var entries = await GetAllForSnapshotAsync(tenantId, snapshotId, null, cancellationToken) .ConfigureAwait(false); var statusCounts = entries .GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); var severityCounts = entries .Where(e => e.Severity is not null) .GroupBy(e => e.Severity!, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false); return new EffectiveDecisionSummary { SnapshotId = snapshotId, TotalAssets = entries.Count, StatusCounts = statusCounts, SeverityCounts = severityCounts, ExceptionCount = entries.Count(e => e.ExceptionId is not null), MapVersion = version, ComputedAt = _timeProvider.GetUtcNow(), }; } public async Task InvalidateAsync( string tenantId, string snapshotId, string assetId, CancellationToken cancellationToken = default) { var entryKey = GetEntryKey(tenantId, snapshotId, assetId); var indexKey = GetIndexKey(tenantId, snapshotId); await _entryCache.InvalidateAsync(entryKey, cancellationToken).ConfigureAwait(false); await _assetIndex.RemoveAsync(indexKey, assetId, cancellationToken).ConfigureAwait(false); await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false); PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1, new KeyValuePair("operation", "invalidate"), new KeyValuePair("tenant_id", tenantId)); } public async Task InvalidateSnapshotAsync( string tenantId, string snapshotId, CancellationToken cancellationToken = default) { var indexKey = GetIndexKey(tenantId, snapshotId); // Get all asset IDs from the index var assetElements = await _assetIndex.GetByRankAsync(indexKey, 0, -1, cancellationToken: cancellationToken).ConfigureAwait(false); var count = assetElements.Count; foreach (var element in assetElements) { var entryKey = GetEntryKey(tenantId, snapshotId, element.Element); await _entryCache.InvalidateAsync(entryKey, cancellationToken).ConfigureAwait(false); } // Delete the index await _assetIndex.DeleteAsync(indexKey, cancellationToken).ConfigureAwait(false); // Delete the version var versionKey = GetVersionKey(tenantId, snapshotId); await _versionCache.InvalidateAsync(versionKey, cancellationToken).ConfigureAwait(false); PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count, new KeyValuePair("operation", "invalidate_snapshot"), new KeyValuePair("tenant_id", tenantId)); _logger.LogInformation("Invalidated {Count} entries for snapshot {SnapshotId}", count, snapshotId); } public async Task InvalidateTenantAsync( string tenantId, CancellationToken cancellationToken = default) { // Invalidate all entries and indexes for the tenant using pattern matching var pattern = $"{EntryKeyPrefix}:{tenantId}:*"; var entryCount = await _entryCache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false); // Note: ISortedIndex doesn't have pattern-based deletion, so we can't easily clean up indexes // This is a limitation of the abstraction - the Redis implementation handled this with KEYS scan PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(entryCount, new KeyValuePair("operation", "invalidate_tenant"), new KeyValuePair("tenant_id", tenantId)); _logger.LogInformation("Invalidated {Count} entries for tenant {TenantId}", entryCount, tenantId); } public async Task GetVersionAsync( string tenantId, string snapshotId, CancellationToken cancellationToken = default) { var versionKey = GetVersionKey(tenantId, snapshotId); var result = await _versionCache.GetAsync(versionKey, cancellationToken).ConfigureAwait(false); return result.HasValue ? result.Value : 0; } public async Task IncrementVersionAsync( string tenantId, string snapshotId, CancellationToken cancellationToken = default) { var versionKey = GetVersionKey(tenantId, snapshotId); var current = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false); var newVersion = current + 1; var cacheOptions = new CacheEntryOptions { TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10) }; await _versionCache.SetAsync(versionKey, newVersion, cacheOptions, cancellationToken).ConfigureAwait(false); return newVersion; } public Task GetStatsAsync( string? tenantId = null, CancellationToken cancellationToken = default) { // Stats require implementation-specific queries that aren't available through abstractions // Return placeholder stats - a complete implementation would need transport-specific code return Task.FromResult(new EffectiveDecisionMapStats { TotalEntries = 0, TotalSnapshots = 0, MemoryUsedBytes = null, ExpiringWithinHour = 0, LastEvictionAt = null, LastEvictionCount = 0, }); } private static string GetEntryKey(string tenantId, string snapshotId, string assetId) => $"{EntryKeyPrefix}:{tenantId}:{snapshotId}:{assetId}"; private static string GetIndexKey(string tenantId, string snapshotId) => $"{IndexKeyPrefix}:{tenantId}:{snapshotId}"; private static string GetVersionKey(string tenantId, string snapshotId) => $"{VersionKeyPrefix}:{tenantId}:{snapshotId}"; }