356 lines
10 KiB
C#
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)
|
|
};
|
|
}
|