Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Digest/DigestScheduler.cs
2026-02-01 21:37:40 +02:00

356 lines
10 KiB
C#

using Cronos;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
namespace StellaOps.Notifier.Worker.Digest;
/// <summary>
/// Manages scheduled digest configurations.
/// </summary>
public interface IDigestScheduler
{
/// <summary>
/// Gets all scheduled digests for a tenant.
/// </summary>
Task<IReadOnlyList<DigestSchedule>> GetSchedulesAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific schedule by ID.
/// </summary>
Task<DigestSchedule?> GetScheduleAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a digest schedule.
/// </summary>
Task<DigestSchedule> UpsertScheduleAsync(
DigestSchedule schedule,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a digest schedule.
/// </summary>
Task<bool> DeleteScheduleAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets schedules due for execution.
/// </summary>
Task<IReadOnlyList<DigestSchedule>> GetDueSchedulesAsync(
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the last run time for a schedule.
/// </summary>
Task UpdateLastRunAsync(
string tenantId,
string scheduleId,
DateTimeOffset runTime,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Digest schedule configuration.
/// </summary>
public sealed record DigestSchedule
{
/// <summary>
/// Unique schedule ID.
/// </summary>
public required string ScheduleId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Schedule name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Whether the schedule is enabled.
/// </summary>
public required bool Enabled { get; init; }
/// <summary>
/// Cron expression for schedule timing (5-field or 6-field).
/// </summary>
public required string CronExpression { get; init; }
/// <summary>
/// Timezone for cron evaluation (IANA format).
/// </summary>
public string? Timezone { get; init; }
/// <summary>
/// Digest type to generate.
/// </summary>
public DigestType DigestType { get; init; } = DigestType.Daily;
/// <summary>
/// Period lookback for the digest (e.g., 24 hours for daily).
/// </summary>
public TimeSpan? LookbackPeriod { get; init; }
/// <summary>
/// Output format.
/// </summary>
public DigestFormat Format { get; init; } = DigestFormat.Html;
/// <summary>
/// Event kind filters (null = all).
/// </summary>
public IReadOnlyList<string>? EventKindFilter { get; init; }
/// <summary>
/// Whether to include resolved incidents.
/// </summary>
public bool IncludeResolved { get; init; } = false;
/// <summary>
/// Recipients for the digest.
/// </summary>
public IReadOnlyList<DigestRecipient>? Recipients { get; init; }
/// <summary>
/// Last successful run time.
/// </summary>
public DateTimeOffset? LastRunAt { get; set; }
/// <summary>
/// Next scheduled run time.
/// </summary>
public DateTimeOffset? NextRunAt { get; set; }
/// <summary>
/// When the schedule was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Who created the schedule.
/// </summary>
public string? CreatedBy { get; init; }
}
/// <summary>
/// Digest recipient configuration.
/// </summary>
public sealed record DigestRecipient
{
/// <summary>
/// Recipient type (email, webhook, channel).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Recipient address (email, URL, channel ID).
/// </summary>
public required string Address { get; init; }
/// <summary>
/// Optional display name.
/// </summary>
public string? Name { get; init; }
}
/// <summary>
/// In-memory implementation of <see cref="IDigestScheduler"/>.
/// </summary>
public sealed class InMemoryDigestScheduler : IDigestScheduler
{
private readonly ConcurrentDictionary<string, DigestSchedule> _schedules = new();
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryDigestScheduler> _logger;
public InMemoryDigestScheduler(
TimeProvider timeProvider,
ILogger<InMemoryDigestScheduler> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<IReadOnlyList<DigestSchedule>> GetSchedulesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var schedules = _schedules.Values
.Where(s => s.TenantId == tenantId)
.OrderBy(s => s.Name)
.ToList();
return Task.FromResult<IReadOnlyList<DigestSchedule>>(schedules);
}
public Task<DigestSchedule?> GetScheduleAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var key = BuildKey(tenantId, scheduleId);
_schedules.TryGetValue(key, out var schedule);
return Task.FromResult(schedule);
}
public Task<DigestSchedule> UpsertScheduleAsync(
DigestSchedule schedule,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
// Calculate next run time
var updatedSchedule = schedule with
{
NextRunAt = CalculateNextRun(schedule)
};
var key = BuildKey(schedule.TenantId, schedule.ScheduleId);
_schedules[key] = updatedSchedule;
_logger.LogInformation(
"Upserted digest schedule {ScheduleId} for tenant {TenantId}, next run at {NextRun}.",
schedule.ScheduleId, schedule.TenantId, updatedSchedule.NextRunAt);
return Task.FromResult(updatedSchedule);
}
public Task<bool> DeleteScheduleAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var key = BuildKey(tenantId, scheduleId);
var removed = _schedules.TryRemove(key, out _);
if (removed)
{
_logger.LogInformation(
"Deleted digest schedule {ScheduleId} for tenant {TenantId}.",
scheduleId, tenantId);
}
return Task.FromResult(removed);
}
public Task<IReadOnlyList<DigestSchedule>> GetDueSchedulesAsync(
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var dueSchedules = _schedules.Values
.Where(s => s.Enabled)
.Where(s => s.NextRunAt.HasValue && s.NextRunAt.Value <= asOf)
.ToList();
return Task.FromResult<IReadOnlyList<DigestSchedule>>(dueSchedules);
}
public Task UpdateLastRunAsync(
string tenantId,
string scheduleId,
DateTimeOffset runTime,
CancellationToken cancellationToken = default)
{
var key = BuildKey(tenantId, scheduleId);
if (_schedules.TryGetValue(key, out var schedule))
{
var updatedSchedule = schedule with
{
LastRunAt = runTime,
NextRunAt = CalculateNextRun(schedule, runTime)
};
_schedules[key] = updatedSchedule;
_logger.LogDebug(
"Updated last run for schedule {ScheduleId}, next run at {NextRun}.",
scheduleId, updatedSchedule.NextRunAt);
}
return Task.CompletedTask;
}
private DateTimeOffset? CalculateNextRun(DigestSchedule schedule, DateTimeOffset? from = null)
{
if (!schedule.Enabled)
{
return null;
}
try
{
var expression = CronExpression.Parse(schedule.CronExpression, CronFormat.IncludeSeconds);
var fromTime = from ?? _timeProvider.GetUtcNow();
TimeZoneInfo tz = TimeZoneInfo.Utc;
if (!string.IsNullOrWhiteSpace(schedule.Timezone))
{
try
{
tz = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone);
}
catch
{
// Invalid timezone, use UTC
}
}
var next = expression.GetNextOccurrence(fromTime.UtcDateTime, tz);
return next.HasValue ? new DateTimeOffset(next.Value, TimeSpan.Zero) : null;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to parse cron expression '{Cron}' for schedule {ScheduleId}.",
schedule.CronExpression, schedule.ScheduleId);
return null;
}
}
private static string BuildKey(string tenantId, string scheduleId) =>
$"{tenantId}:{scheduleId}";
}
/// <summary>
/// Configuration options for digest scheduling.
/// </summary>
public sealed class DigestSchedulerOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Notifier:DigestScheduler";
/// <summary>
/// How often to check for due schedules.
/// </summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Maximum concurrent digest generations.
/// </summary>
public int MaxConcurrent { get; set; } = 5;
/// <summary>
/// Default lookback periods by digest type.
/// </summary>
public Dictionary<DigestType, TimeSpan> DefaultLookbacks { get; set; } = new()
{
[DigestType.Daily] = TimeSpan.FromHours(24),
[DigestType.Weekly] = TimeSpan.FromDays(7),
[DigestType.Monthly] = TimeSpan.FromDays(30)
};
}