using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; using System.Security.Cryptography; using System.Text; namespace StellaOps.Policy.Engine.IncrementalOrchestrator; /// /// Configuration options for the incremental policy orchestrator. /// public sealed record IncrementalOrchestratorOptions { /// /// How often to poll for new change events. /// public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5); /// /// How long to wait before batching events together. /// public TimeSpan BatchWindow { get; init; } = TimeSpan.FromSeconds(10); /// /// Maximum events per batch. /// public int MaxBatchSize { get; init; } = 100; /// /// Maximum retry attempts for failed processing. /// public int MaxRetryAttempts { get; init; } = 3; /// /// Delay between retry attempts. /// public TimeSpan RetryBackoff { get; init; } = TimeSpan.FromSeconds(5); /// /// Whether to enable deduplication within batch window. /// public bool EnableDeduplication { get; init; } = true; /// /// Maximum age of events to process (older events are skipped). /// public TimeSpan MaxEventAge { get; init; } = TimeSpan.FromHours(24); /// /// Default options. /// public static IncrementalOrchestratorOptions Default { get; } = new(); } /// /// Interface for reading change events from a source. /// public interface IPolicyChangeEventSource { /// /// Reads pending change events. /// IAsyncEnumerable ReadAsync(CancellationToken cancellationToken); /// /// Acknowledges that an event has been processed. /// Task AcknowledgeAsync(string eventId, CancellationToken cancellationToken); /// /// Marks an event as failed for retry. /// Task MarkFailedAsync(string eventId, string error, CancellationToken cancellationToken); } /// /// Interface for submitting policy re-evaluation jobs. /// public interface IPolicyReEvaluationSubmitter { /// /// Submits a batch for re-evaluation. /// Task SubmitAsync( PolicyChangeBatch batch, CancellationToken cancellationToken); } /// /// Interface for idempotency tracking. /// public interface IPolicyChangeIdempotencyStore { /// /// Checks if an event has already been processed. /// Task HasSeenAsync(string eventId, CancellationToken cancellationToken); /// /// Marks an event as processed. /// Task MarkSeenAsync(string eventId, DateTimeOffset processedAt, CancellationToken cancellationToken); } /// /// Result of a policy re-evaluation submission. /// public sealed record PolicyReEvaluationResult { /// /// Whether the submission succeeded. /// public required bool Succeeded { get; init; } /// /// Job ID(s) created for the re-evaluation. /// public required ImmutableArray JobIds { get; init; } /// /// Number of findings that will be re-evaluated. /// public int EstimatedFindingsCount { get; init; } /// /// Error message if failed. /// public string? Error { get; init; } /// /// Processing duration. /// public long ProcessingTimeMs { get; init; } } /// /// Orchestrates incremental policy re-evaluations in response to /// advisory, VEX, and SBOM change streams. /// public sealed class IncrementalPolicyOrchestrator { private readonly IPolicyChangeEventSource _eventSource; private readonly IPolicyReEvaluationSubmitter _submitter; private readonly IPolicyChangeIdempotencyStore _idempotencyStore; private readonly IncrementalOrchestratorOptions _options; private readonly TimeProvider _timeProvider; public IncrementalPolicyOrchestrator( IPolicyChangeEventSource eventSource, IPolicyReEvaluationSubmitter submitter, IPolicyChangeIdempotencyStore idempotencyStore, IncrementalOrchestratorOptions? options = null, TimeProvider? timeProvider = null) { _eventSource = eventSource ?? throw new ArgumentNullException(nameof(eventSource)); _submitter = submitter ?? throw new ArgumentNullException(nameof(submitter)); _idempotencyStore = idempotencyStore ?? throw new ArgumentNullException(nameof(idempotencyStore)); _options = options ?? IncrementalOrchestratorOptions.Default; _timeProvider = timeProvider ?? TimeProvider.System; } /// /// Processes a single batch of pending events. /// public async Task ProcessAsync(CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); var now = _timeProvider.GetUtcNow(); var cutoffTime = now - _options.MaxEventAge; var eventsByTenant = new Dictionary>(StringComparer.OrdinalIgnoreCase); var skippedOld = 0; var skippedDuplicate = 0; var totalRead = 0; // Read and group events by tenant await foreach (var evt in _eventSource.ReadAsync(cancellationToken)) { totalRead++; // Skip events older than max age if (evt.OccurredAt < cutoffTime) { skippedOld++; await _eventSource.AcknowledgeAsync(evt.EventId, cancellationToken).ConfigureAwait(false); continue; } // Check idempotency if (_options.EnableDeduplication && await _idempotencyStore.HasSeenAsync(evt.EventId, cancellationToken).ConfigureAwait(false)) { skippedDuplicate++; await _eventSource.AcknowledgeAsync(evt.EventId, cancellationToken).ConfigureAwait(false); continue; } if (!eventsByTenant.TryGetValue(evt.TenantId, out var tenantEvents)) { tenantEvents = new List(); eventsByTenant[evt.TenantId] = tenantEvents; } tenantEvents.Add(evt); // Limit total events per processing cycle if (totalRead >= _options.MaxBatchSize * 10) { break; } } var batchesProcessed = 0; var batchesFailed = 0; var jobsCreated = new List(); // Process each tenant's events foreach (var (tenantId, events) in eventsByTenant.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) { var batches = CreateBatches(tenantId, events, now); foreach (var batch in batches) { var attempts = 0; var success = false; while (attempts < _options.MaxRetryAttempts && !success) { try { cancellationToken.ThrowIfCancellationRequested(); var result = await _submitter.SubmitAsync(batch, cancellationToken).ConfigureAwait(false); if (result.Succeeded) { success = true; batchesProcessed++; jobsCreated.AddRange(result.JobIds); // Mark all events in batch as seen foreach (var evt in batch.Events) { await _idempotencyStore.MarkSeenAsync(evt.EventId, now, cancellationToken) .ConfigureAwait(false); await _eventSource.AcknowledgeAsync(evt.EventId, cancellationToken) .ConfigureAwait(false); } } else { attempts++; if (attempts < _options.MaxRetryAttempts) { await Task.Delay(_options.RetryBackoff, cancellationToken).ConfigureAwait(false); } } } catch (OperationCanceledException) { throw; } catch (Exception ex) { attempts++; if (attempts >= _options.MaxRetryAttempts) { batchesFailed++; foreach (var evt in batch.Events) { await _eventSource.MarkFailedAsync(evt.EventId, ex.Message, cancellationToken) .ConfigureAwait(false); } } else { await Task.Delay(_options.RetryBackoff, cancellationToken).ConfigureAwait(false); } } } } } stopwatch.Stop(); return new OrchestratorProcessResult { TotalEventsRead = totalRead, EventsSkippedOld = skippedOld, EventsSkippedDuplicate = skippedDuplicate, BatchesProcessed = batchesProcessed, BatchesFailed = batchesFailed, JobsCreated = jobsCreated.ToImmutableArray(), ProcessingTimeMs = stopwatch.ElapsedMilliseconds }; } /// /// Creates deterministically ordered batches from events. /// private IReadOnlyList CreateBatches( string tenantId, IReadOnlyList events, DateTimeOffset now) { // Sort by priority (highest first), then by occurred time var ordered = events .OrderByDescending(e => (int)e.Priority) .ThenBy(e => e.OccurredAt) .ThenBy(e => e.EventId, StringComparer.Ordinal) .ToList(); var batches = new List(); var currentBatch = new List(); var currentPriority = PolicyChangePriority.Normal; foreach (var evt in ordered) { // Start new batch if priority changes or batch is full if (currentBatch.Count > 0 && (evt.Priority != currentPriority || currentBatch.Count >= _options.MaxBatchSize)) { batches.Add(CreateBatchFromEvents(tenantId, currentBatch, currentPriority, now)); currentBatch = new List(); } currentBatch.Add(evt); currentPriority = evt.Priority; } // Add final batch if (currentBatch.Count > 0) { batches.Add(CreateBatchFromEvents(tenantId, currentBatch, currentPriority, now)); } return batches; } private static PolicyChangeBatch CreateBatchFromEvents( string tenantId, IReadOnlyList events, PolicyChangePriority priority, DateTimeOffset createdAt) { var batchId = CreateBatchId(tenantId, events, createdAt); // Aggregate all affected items var allPurls = events .SelectMany(e => e.AffectedPurls) .Where(p => !string.IsNullOrWhiteSpace(p)) .Distinct(StringComparer.Ordinal) .OrderBy(p => p, StringComparer.Ordinal) .ToImmutableArray(); var allProductKeys = events .SelectMany(e => e.AffectedProductKeys) .Where(k => !string.IsNullOrWhiteSpace(k)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); var allSbomIds = events .SelectMany(e => e.AffectedSbomIds) .Where(id => !string.IsNullOrWhiteSpace(id)) .Distinct(StringComparer.Ordinal) .OrderBy(id => id, StringComparer.Ordinal) .ToImmutableArray(); var allVulnIds = events .Select(e => e.VulnerabilityId) .Where(v => !string.IsNullOrWhiteSpace(v)) .Cast() .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); return new PolicyChangeBatch { BatchId = batchId, TenantId = tenantId, Events = events.ToImmutableArray(), Priority = priority, CreatedAt = createdAt, AffectedPurls = allPurls, AffectedProductKeys = allProductKeys, AffectedSbomIds = allSbomIds, VulnerabilityIds = allVulnIds }; } private static string CreateBatchId( string tenantId, IReadOnlyList events, DateTimeOffset createdAt) { var builder = new StringBuilder(); builder.Append(tenantId).Append('|'); builder.Append(createdAt.ToString("O", CultureInfo.InvariantCulture)).Append('|'); foreach (var evt in events.OrderBy(e => e.EventId, StringComparer.Ordinal)) { builder.Append(evt.EventId).Append('|'); } var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); return $"pcb-{Convert.ToHexStringLower(bytes)[..16]}"; } } /// /// Result of an orchestrator processing cycle. /// public sealed record OrchestratorProcessResult { /// /// Total events read from source. /// public required int TotalEventsRead { get; init; } /// /// Events skipped due to age. /// public required int EventsSkippedOld { get; init; } /// /// Events skipped due to deduplication. /// public required int EventsSkippedDuplicate { get; init; } /// /// Batches successfully processed. /// public required int BatchesProcessed { get; init; } /// /// Batches that failed after retries. /// public required int BatchesFailed { get; init; } /// /// Job IDs created during processing. /// public required ImmutableArray JobsCreated { get; init; } /// /// Total processing time in milliseconds. /// public required long ProcessingTimeMs { get; init; } /// /// Whether any work was done. /// public bool HasWork => TotalEventsRead > 0; /// /// Whether all batches succeeded. /// public bool AllSucceeded => BatchesFailed == 0; } /// /// In-memory implementation of policy change event source for testing. /// public sealed class InMemoryPolicyChangeEventSource : IPolicyChangeEventSource { private readonly ConcurrentQueue _pending = new(); private readonly ConcurrentDictionary _failed = new(); private readonly ConcurrentDictionary _acknowledged = new(); public void Enqueue(PolicyChangeEvent evt) { _pending.Enqueue(evt); } public void EnqueueRange(IEnumerable events) { foreach (var evt in events) { _pending.Enqueue(evt); } } public async IAsyncEnumerable ReadAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { while (_pending.TryDequeue(out var evt)) { cancellationToken.ThrowIfCancellationRequested(); yield return evt; } await Task.CompletedTask; } public Task AcknowledgeAsync(string eventId, CancellationToken cancellationToken) { // Remove from failed if retrying _failed.TryRemove(eventId, out _); return Task.CompletedTask; } public Task MarkFailedAsync(string eventId, string error, CancellationToken cancellationToken) { // Events could be tracked for retry return Task.CompletedTask; } public int PendingCount => _pending.Count; public IReadOnlyCollection GetAcknowledged() => _acknowledged.Values.ToList(); } /// /// In-memory implementation of idempotency store for testing. /// public sealed class InMemoryPolicyChangeIdempotencyStore : IPolicyChangeIdempotencyStore { private readonly ConcurrentDictionary _seen = new(StringComparer.Ordinal); public Task HasSeenAsync(string eventId, CancellationToken cancellationToken) { return Task.FromResult(_seen.ContainsKey(eventId)); } public Task MarkSeenAsync(string eventId, DateTimeOffset processedAt, CancellationToken cancellationToken) { _seen[eventId] = processedAt; return Task.CompletedTask; } public int SeenCount => _seen.Count; public void Clear() => _seen.Clear(); }