using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs; /// /// Lightweight in-memory resolver job service to satisfy API contract and rate-limit callers. /// Suitable for stub/air-gap scenarios; replace with Mongo-backed implementation when ready. /// public sealed class InMemoryResolverJobService : IResolverJobService { private readonly ConcurrentDictionary _store = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary> _tenantCreates = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; private const int MaxJobsPerMinute = 60; public InMemoryResolverJobService(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } public Task CreateAsync(string tenantId, ResolverJobRequest request, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNull(request); ValidateRequest(request); EnforceRateLimit(tenantId); var id = GenerateId(tenantId, request.ArtifactId, request.PolicyId); var created = _timeProvider.GetUtcNow(); var response = new ResolverJobResponse( id, request.ArtifactId.Trim(), request.PolicyId.Trim(), "queued", created, CompletedAt: null, request.CorrelationId, request.Metadata ?? new Dictionary()); _store[id] = response; TrackCreate(tenantId, created); return Task.FromResult(response); } public Task GetAsync(string tenantId, string jobId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(jobId); _store.TryGetValue(jobId, out var response); return Task.FromResult(response); } public ResolverBacklogMetricsResponse ComputeMetrics(string tenantId) { var now = _timeProvider.GetUtcNow(); var pending = new List(); var completed = new List(); foreach (var job in _store.Values) { if (string.Equals(job.Status, "completed", StringComparison.OrdinalIgnoreCase)) { completed.Add(job); } else { pending.Add(job); } } var lagEntries = completed .Where(j => j.CompletedAt is not null) .Select(j => new ResolverLagEntry( j.Id, j.CompletedAt!.Value, Math.Max((j.CompletedAt!.Value - j.CreatedAt).TotalSeconds, 0d), j.CorrelationId, j.ArtifactId, j.PolicyId)) .OrderByDescending(e => e.CompletedAt) .ToList(); return new ResolverBacklogMetricsResponse( tenantId, Pending: pending.Count, Running: 0, Completed: completed.Count, Failed: 0, MinLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Min(e => (double?)e.LagSeconds), MaxLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Max(e => (double?)e.LagSeconds), AverageLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Average(e => e.LagSeconds), RecentCompleted: lagEntries.Take(5).ToList()); } private static void ValidateRequest(ResolverJobRequest request) { if (string.IsNullOrWhiteSpace(request.ArtifactId)) { throw new ValidationException("artifactId is required."); } if (string.IsNullOrWhiteSpace(request.PolicyId)) { throw new ValidationException("policyId is required."); } } private static string GenerateId(string tenantId, string artifactId, string policyId) { var raw = $"{tenantId}:{artifactId}:{policyId}:{Guid.NewGuid():N}"; return "resolver-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(raw))).ToLowerInvariant(); } private void EnforceRateLimit(string tenantId) { var now = _timeProvider.GetUtcNow(); var cutoff = now.AddMinutes(-1); var list = _tenantCreates.GetOrAdd(tenantId, static _ => new List()); lock (list) { list.RemoveAll(ts => ts < cutoff); if (list.Count >= MaxJobsPerMinute) { throw new InvalidOperationException("resolver job rate limit exceeded"); } } } private void TrackCreate(string tenantId, DateTimeOffset timestamp) { var list = _tenantCreates.GetOrAdd(tenantId, static _ => new List()); lock (list) { list.Add(timestamp); } } }