using System.Collections.Immutable; using Microsoft.Extensions.Logging; using StellaOps.Scheduler.Models; using StellaOps.Scheduler.Storage.Mongo.Documents; using StellaOps.Scheduler.Storage.Mongo.Projections; using StellaOps.Scheduler.Storage.Mongo.Repositories; namespace StellaOps.Scheduler.Storage.Mongo.Services; internal sealed class RunSummaryService : IRunSummaryService { private const int RecentLimit = 20; private readonly IRunSummaryRepository _repository; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public RunSummaryService( IRunSummaryRepository repository, TimeProvider? timeProvider, ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task ProjectAsync( Run run, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(run); if (string.IsNullOrWhiteSpace(run.ScheduleId)) { throw new ArgumentException("Run must contain a scheduleId to project summary data.", nameof(run)); } var document = await _repository .GetAsync(run.TenantId, run.ScheduleId!, cancellationToken) .ConfigureAwait(false) ?? new RunSummaryDocument { TenantId = run.TenantId, ScheduleId = run.ScheduleId!, }; UpdateDocument(document, run); document.UpdatedAt = _timeProvider.GetUtcNow().UtcDateTime; await _repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false); _logger.LogDebug( "Projected run summary for tenant {TenantId} schedule {ScheduleId} using run {RunId}.", run.TenantId, run.ScheduleId, run.Id); return ToProjection(document); } public async Task GetAsync( string tenantId, string scheduleId, CancellationToken cancellationToken = default) { var document = await _repository .GetAsync(tenantId, scheduleId, cancellationToken) .ConfigureAwait(false); return document is null ? null : ToProjection(document); } public async Task> ListAsync( string tenantId, CancellationToken cancellationToken = default) { var documents = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); return documents.Select(ToProjection).ToArray(); } private static void UpdateDocument(RunSummaryDocument document, Run run) { var entry = document.Recent.FirstOrDefault(item => string.Equals(item.RunId, run.Id, StringComparison.Ordinal)); if (entry is null) { entry = new RunSummaryEntryDocument { RunId = run.Id, }; document.Recent.Add(entry); } entry.Trigger = run.Trigger; entry.State = run.State; entry.CreatedAt = run.CreatedAt.UtcDateTime; entry.StartedAt = run.StartedAt?.UtcDateTime; entry.FinishedAt = run.FinishedAt?.UtcDateTime; entry.Error = run.Error; entry.Stats = run.Stats; document.Recent = document.Recent .OrderByDescending(item => item.CreatedAt) .ThenByDescending(item => item.RunId, StringComparer.Ordinal) .Take(RecentLimit) .ToList(); document.LastRun = document.Recent.FirstOrDefault(); document.Counters = ComputeCounters(document.Recent); } private static RunSummaryCountersDocument ComputeCounters(IEnumerable entries) { var counters = new RunSummaryCountersDocument(); foreach (var entry in entries) { counters.Total++; switch (entry.State) { case RunState.Planning: counters.Planning++; break; case RunState.Queued: counters.Queued++; break; case RunState.Running: counters.Running++; break; case RunState.Completed: counters.Completed++; break; case RunState.Error: counters.Error++; break; case RunState.Cancelled: counters.Cancelled++; break; default: break; } counters.TotalDeltas += entry.Stats.Deltas; counters.TotalNewCriticals += entry.Stats.NewCriticals; counters.TotalNewHigh += entry.Stats.NewHigh; counters.TotalNewMedium += entry.Stats.NewMedium; counters.TotalNewLow += entry.Stats.NewLow; } return counters; } private static RunSummaryProjection ToProjection(RunSummaryDocument document) { var updatedAt = new DateTimeOffset(DateTime.SpecifyKind(document.UpdatedAt, DateTimeKind.Utc)); var lastRun = document.LastRun is null ? null : ToSnapshot(document.LastRun); var recent = document.Recent .Select(ToSnapshot) .ToImmutableArray(); var counters = new RunSummaryCounters( document.Counters.Total, document.Counters.Planning, document.Counters.Queued, document.Counters.Running, document.Counters.Completed, document.Counters.Error, document.Counters.Cancelled, document.Counters.TotalDeltas, document.Counters.TotalNewCriticals, document.Counters.TotalNewHigh, document.Counters.TotalNewMedium, document.Counters.TotalNewLow); return new RunSummaryProjection( document.TenantId, document.ScheduleId, updatedAt, lastRun, recent, counters); } private static RunSummarySnapshot ToSnapshot(RunSummaryEntryDocument entry) { var createdAt = new DateTimeOffset(DateTime.SpecifyKind(entry.CreatedAt, DateTimeKind.Utc)); DateTimeOffset? startedAt = entry.StartedAt is null ? null : new DateTimeOffset(DateTime.SpecifyKind(entry.StartedAt.Value, DateTimeKind.Utc)); DateTimeOffset? finishedAt = entry.FinishedAt is null ? null : new DateTimeOffset(DateTime.SpecifyKind(entry.FinishedAt.Value, DateTimeKind.Utc)); return new RunSummarySnapshot( entry.RunId, entry.Trigger, entry.State, createdAt, startedAt, finishedAt, entry.Stats, entry.Error); } }