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();
}