using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Notify.Models; using StellaOps.Notifier.Worker.Channels; namespace StellaOps.Notifier.Worker.Digest; /// /// Background service that runs digest generation on configured schedules. /// public sealed class DigestScheduleRunner : BackgroundService { private readonly IDigestGenerator _digestGenerator; private readonly IDigestDistributor _distributor; private readonly IDigestTenantProvider _tenantProvider; private readonly DigestScheduleOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public DigestScheduleRunner( IDigestGenerator digestGenerator, IDigestDistributor distributor, IDigestTenantProvider tenantProvider, IOptions options, TimeProvider timeProvider, ILogger logger) { _digestGenerator = digestGenerator ?? throw new ArgumentNullException(nameof(digestGenerator)); _distributor = distributor ?? throw new ArgumentNullException(nameof(distributor)); _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { if (!_options.Enabled) { _logger.LogInformation("Digest schedule runner is disabled."); return; } _logger.LogInformation( "Digest schedule runner started with {ScheduleCount} schedules.", _options.Schedules.Count); // Run each schedule in parallel var scheduleTasks = _options.Schedules .Where(s => s.Enabled) .Select(schedule => RunScheduleAsync(schedule, stoppingToken)) .ToList(); await Task.WhenAll(scheduleTasks); } private async Task RunScheduleAsync(DigestScheduleConfig schedule, CancellationToken stoppingToken) { _logger.LogInformation( "Starting digest schedule '{Name}' with interval {Interval}.", schedule.Name, schedule.Interval); // Calculate initial delay to align with schedule var initialDelay = CalculateInitialDelay(schedule); if (initialDelay > TimeSpan.Zero) { _logger.LogDebug( "Schedule '{Name}' waiting {Delay} for initial alignment.", schedule.Name, initialDelay); await Task.Delay(initialDelay, stoppingToken); } while (!stoppingToken.IsCancellationRequested) { try { await ExecuteScheduleAsync(schedule, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { _logger.LogError(ex, "Error executing digest schedule '{Name}'. Will retry after interval.", schedule.Name); } await Task.Delay(schedule.Interval, stoppingToken); } _logger.LogInformation("Digest schedule '{Name}' stopped.", schedule.Name); } private async Task ExecuteScheduleAsync(DigestScheduleConfig schedule, CancellationToken stoppingToken) { var now = _timeProvider.GetUtcNow(); var query = new DigestQuery { From = now - schedule.LookbackPeriod, To = now, IncludeResolved = schedule.IncludeResolved, MaxIncidents = schedule.MaxIncidents, ScheduleName = schedule.Name }; _logger.LogDebug( "Executing schedule '{Name}' for period {From} to {To}.", schedule.Name, query.From, query.To); // Get tenants to generate digests for var tenants = await _tenantProvider.GetTenantsForScheduleAsync(schedule.Name, stoppingToken); var successCount = 0; var errorCount = 0; foreach (var tenantId in tenants) { try { var result = await _digestGenerator.GenerateAsync(tenantId, query, stoppingToken); if (!result.Summary.HasActivity && _options.SkipEmptyDigests) { _logger.LogDebug( "Skipping empty digest for tenant {TenantId} on schedule '{Schedule}'.", tenantId, schedule.Name); continue; } await _distributor.DistributeAsync(result, schedule, stoppingToken); successCount++; _logger.LogInformation( "Distributed digest {DigestId} for tenant {TenantId} via schedule '{Schedule}'.", result.DigestId, tenantId, schedule.Name); } catch (Exception ex) { errorCount++; _logger.LogError(ex, "Failed to generate/distribute digest for tenant {TenantId} on schedule '{Schedule}'.", tenantId, schedule.Name); } } _logger.LogInformation( "Completed schedule '{Name}': {Success} successful, {Errors} errors out of {Total} tenants.", schedule.Name, successCount, errorCount, tenants.Count); } private TimeSpan CalculateInitialDelay(DigestScheduleConfig schedule) { if (!schedule.AlignToInterval) { return TimeSpan.Zero; } var now = _timeProvider.GetUtcNow(); var intervalTicks = schedule.Interval.Ticks; var currentTicks = now.Ticks; // Calculate next aligned time var nextAlignedTicks = ((currentTicks / intervalTicks) + 1) * intervalTicks; var nextAligned = new DateTimeOffset(nextAlignedTicks, TimeSpan.Zero); return nextAligned - now; } } /// /// Distributes generated digests through configured channels. /// public interface IDigestDistributor { /// /// Distributes a digest via the appropriate channels. /// Task DistributeAsync( DigestResult digest, DigestScheduleConfig schedule, CancellationToken cancellationToken = default); } /// /// Provides tenants that should receive digests for a given schedule. /// public interface IDigestTenantProvider { /// /// Gets tenant IDs that should receive digests for the specified schedule. /// Task> GetTenantsForScheduleAsync( string scheduleName, CancellationToken cancellationToken = default); } /// /// Default implementation of using channel adapters. /// public sealed class ChannelDigestDistributor : IDigestDistributor { private readonly IChannelAdapterFactory _channelFactory; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public ChannelDigestDistributor( IChannelAdapterFactory channelFactory, TimeProvider timeProvider, ILogger logger) { _channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory)); _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task DistributeAsync( DigestResult digest, DigestScheduleConfig schedule, CancellationToken cancellationToken = default) { foreach (var channelConfig in schedule.Channels) { try { if (!Enum.TryParse(channelConfig.Type, true, out var channelType)) { _logger.LogWarning("Unsupported digest channel type {ChannelType}.", channelConfig.Type); continue; } var adapter = _channelFactory.GetAdapter(channelType); if (adapter is null) { _logger.LogWarning("No adapter registered for digest channel {ChannelType}.", channelType); continue; } var metadata = BuildMetadata(digest, schedule, channelConfig); var channel = BuildChannel(channelType, digest, schedule, channelConfig); var delivery = BuildDelivery(digest, channelType, metadata); var content = SelectContent(digest, channelConfig.Type); var context = new ChannelDispatchContext( delivery.DeliveryId, digest.TenantId, channel, delivery, content, $"Notification Digest - {digest.TenantId}", metadata, _timeProvider.GetUtcNow(), TraceId: $"digest-{digest.DigestId}"); var result = await adapter.DispatchAsync(context, cancellationToken).ConfigureAwait(false); if (result.Success) { _logger.LogDebug( "Sent digest {DigestId} to channel {Channel} ({Destination}).", digest.DigestId, channelType, channelConfig.Destination); } else { _logger.LogWarning( "Digest {DigestId} dispatch to {Channel} failed: {Message}.", digest.DigestId, channelType, result.Message ?? "dispatch failed"); } } catch (Exception ex) { _logger.LogError(ex, "Failed to send digest {DigestId} to channel {Channel}.", digest.DigestId, channelConfig.Type); } } } private static IReadOnlyDictionary BuildMetadata( DigestResult digest, DigestScheduleConfig schedule, DigestChannelConfig channelConfig) { return new Dictionary(StringComparer.Ordinal) { ["digestId"] = digest.DigestId, ["tenantId"] = digest.TenantId, ["scheduleName"] = schedule.Name, ["from"] = digest.From.ToString("O"), ["to"] = digest.To.ToString("O"), ["destination"] = channelConfig.Destination, ["channelType"] = channelConfig.Type }; } private static NotifyChannel BuildChannel( NotifyChannelType channelType, DigestResult digest, DigestScheduleConfig schedule, DigestChannelConfig channelConfig) { var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["destination"] = channelConfig.Destination }; if (!string.IsNullOrWhiteSpace(channelConfig.Format)) { properties["format"] = channelConfig.Format!; } var config = NotifyChannelConfig.Create( secretRef: $"digest-{schedule.Name}", target: channelConfig.Destination, endpoint: channelConfig.Destination, properties: properties); return NotifyChannel.Create( channelId: $"digest-{schedule.Name}-{channelType}".ToLowerInvariant(), tenantId: digest.TenantId, name: $"{schedule.Name}-{channelType}", type: channelType, config: config, enabled: true, metadata: properties); } private static NotifyDelivery BuildDelivery( DigestResult digest, NotifyChannelType channelType, IReadOnlyDictionary metadata) { return NotifyDelivery.Create( deliveryId: $"digest-{digest.DigestId}-{channelType}".ToLowerInvariant(), tenantId: digest.TenantId, ruleId: "digest", actionId: channelType.ToString(), eventId: Guid.NewGuid(), kind: "digest", status: NotifyDeliveryStatus.Sending, statusReason: null, rendered: null, attempts: Array.Empty(), metadata: metadata, createdAt: digest.GeneratedAt, sentAt: null, completedAt: null); } private static string SelectContent(DigestResult digest, string channelType) { if (digest.Content is null) { return string.Empty; } return channelType.ToLowerInvariant() switch { "slack" => digest.Content.SlackBlocks ?? digest.Content.Markdown ?? digest.Content.PlainText ?? "", "email" => digest.Content.Html ?? digest.Content.PlainText ?? "", "webhook" => digest.Content.Json ?? "", _ => digest.Content.PlainText ?? "" }; } } /// /// In-memory implementation for testing. /// public sealed class InMemoryDigestTenantProvider : IDigestTenantProvider { private readonly List _tenants = []; public void AddTenant(string tenantId) => _tenants.Add(tenantId); public void RemoveTenant(string tenantId) => _tenants.Remove(tenantId); public Task> GetTenantsForScheduleAsync( string scheduleName, CancellationToken cancellationToken = default) { return Task.FromResult>(_tenants.ToList()); } } /// /// Configuration options for digest scheduling. /// public sealed class DigestScheduleOptions { /// /// Configuration section name. /// public const string SectionName = "Notifier:DigestSchedule"; /// /// Whether the digest scheduler is enabled. /// public bool Enabled { get; set; } = true; /// /// Whether to skip digests with no activity. /// public bool SkipEmptyDigests { get; set; } = true; /// /// Configured digest schedules. /// public List Schedules { get; set; } = []; } /// /// A single digest schedule configuration. /// public sealed class DigestScheduleConfig { /// /// Unique name for this schedule. /// public required string Name { get; set; } /// /// Whether this schedule is enabled. /// public bool Enabled { get; set; } = true; /// /// How often to run this schedule. /// public TimeSpan Interval { get; set; } = TimeSpan.FromHours(24); /// /// How far back to look for incidents. /// public TimeSpan LookbackPeriod { get; set; } = TimeSpan.FromHours(24); /// /// Whether to align execution to interval boundaries. /// public bool AlignToInterval { get; set; } = true; /// /// Maximum incidents to include. /// public int MaxIncidents { get; set; } = 50; /// /// Whether to include resolved incidents. /// public bool IncludeResolved { get; set; } /// /// Channels to distribute digests through. /// public List Channels { get; set; } = []; } /// /// Channel configuration for digest distribution. /// public sealed class DigestChannelConfig { /// /// Channel type (email, slack, webhook, etc.). /// public required string Type { get; set; } /// /// Destination (email address, webhook URL, channel ID, etc.). /// public required string Destination { get; set; } /// /// Content format to use (html, markdown, json, blocks). /// public string? Format { get; set; } } /// /// Message to send through a channel. /// public sealed class ChannelMessage { public required string ChannelType { get; init; } public required string Destination { get; init; } public string? Subject { get; init; } public required string Body { get; init; } public string Format { get; init; } = "text"; public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); }