Refactor and update test projects, remove obsolete tests, and upgrade dependencies

- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory.
- Removed unused TestDataFactory class.
- Updated project files for Mongo.Tests to remove references to deleted files.
- Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects.
- Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project.
- Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library.
- Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries.
- Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious.
- Updated JsonSchema.Net package to version 7.3.2 in Microservice project.
- Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
master
2025-12-10 19:13:29 +02:00
parent a3c7fe5e88
commit b7059d523e
369 changed files with 11125 additions and 14245 deletions

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />

View File

@@ -25,27 +25,4 @@ public interface INotifyChannelAdapter
CancellationToken cancellationToken);
}
/// <summary>
/// Result of a channel dispatch attempt.
/// </summary>
public sealed record ChannelDispatchResult
{
public required bool Success { get; init; }
public int? StatusCode { get; init; }
public string? Reason { get; init; }
public bool ShouldRetry { get; init; }
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
{
Success = true,
StatusCode = statusCode
};
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
{
Success = false,
StatusCode = statusCode,
Reason = reason,
ShouldRetry = shouldRetry
};
}
// Note: ChannelDispatchResult is defined in IChannelAdapter.cs

View File

@@ -1,221 +0,0 @@
using Cronos;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Default implementation of quiet hours evaluator using cron expressions.
/// </summary>
public sealed class DefaultQuietHoursEvaluator : IQuietHoursEvaluator
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<DefaultQuietHoursEvaluator> _logger;
private readonly INotifyQuietHoursRepository? _quietHoursRepository;
private readonly INotifyMaintenanceWindowRepository? _maintenanceWindowRepository;
private readonly INotifyOperatorOverrideRepository? _operatorOverrideRepository;
// In-memory fallback for testing
private readonly List<NotifyQuietHoursSchedule> _schedules = [];
private readonly List<NotifyMaintenanceWindow> _maintenanceWindows = [];
public DefaultQuietHoursEvaluator(
TimeProvider timeProvider,
ILogger<DefaultQuietHoursEvaluator> logger,
INotifyQuietHoursRepository? quietHoursRepository = null,
INotifyMaintenanceWindowRepository? maintenanceWindowRepository = null,
INotifyOperatorOverrideRepository? operatorOverrideRepository = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_quietHoursRepository = quietHoursRepository;
_maintenanceWindowRepository = maintenanceWindowRepository;
_operatorOverrideRepository = operatorOverrideRepository;
}
public async Task<QuietHoursCheckResult> IsInQuietHoursAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
// Check for active bypass override
if (_operatorOverrideRepository is not null)
{
var overrides = await _operatorOverrideRepository.ListActiveAsync(
tenantId, now, NotifyOverrideType.BypassQuietHours, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (overrides.Count > 0)
{
_logger.LogDebug(
"Quiet hours bypassed by operator override for tenant {TenantId}: override={OverrideId}",
tenantId, overrides[0].OverrideId);
return new QuietHoursCheckResult
{
IsInQuietHours = false,
Reason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
};
}
}
// Find applicable schedules for this tenant
IEnumerable<NotifyQuietHoursSchedule> applicableSchedules;
if (_quietHoursRepository is not null)
{
var schedules = await _quietHoursRepository.ListEnabledAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
applicableSchedules = schedules;
}
else
{
applicableSchedules = _schedules
.Where(s => s.TenantId == tenantId && s.Enabled)
.Where(s => channelId is null || s.ChannelId is null || s.ChannelId == channelId);
}
foreach (var schedule in applicableSchedules)
{
if (IsInSchedule(schedule, now, out var endsAt))
{
_logger.LogDebug(
"Quiet hours active for tenant {TenantId}: schedule={ScheduleId}, endsAt={EndsAt}",
tenantId, schedule.ScheduleId, endsAt);
return new QuietHoursCheckResult
{
IsInQuietHours = true,
QuietHoursScheduleId = schedule.ScheduleId,
QuietHoursEndsAt = endsAt,
Reason = $"Quiet hours: {schedule.Name}"
};
}
}
return new QuietHoursCheckResult
{
IsInQuietHours = false
};
}
public async Task<MaintenanceCheckResult> IsInMaintenanceAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
// Check for active bypass override
if (_operatorOverrideRepository is not null)
{
var overrides = await _operatorOverrideRepository.ListActiveAsync(
tenantId, now, NotifyOverrideType.BypassMaintenance, cancellationToken: cancellationToken).ConfigureAwait(false);
if (overrides.Count > 0)
{
_logger.LogDebug(
"Maintenance window bypassed by operator override for tenant {TenantId}: override={OverrideId}",
tenantId, overrides[0].OverrideId);
return new MaintenanceCheckResult
{
IsInMaintenance = false,
MaintenanceReason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
};
}
}
// Find active maintenance windows
NotifyMaintenanceWindow? activeWindow;
if (_maintenanceWindowRepository is not null)
{
var windows = await _maintenanceWindowRepository.GetActiveAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
activeWindow = windows.FirstOrDefault();
}
else
{
activeWindow = _maintenanceWindows
.Where(w => w.TenantId == tenantId && w.SuppressNotifications)
.FirstOrDefault(w => w.IsActiveAt(now));
}
if (activeWindow is not null)
{
_logger.LogDebug(
"Maintenance window active for tenant {TenantId}: window={WindowId}, endsAt={EndsAt}",
tenantId, activeWindow.WindowId, activeWindow.EndsAt);
return new MaintenanceCheckResult
{
IsInMaintenance = true,
MaintenanceWindowId = activeWindow.WindowId,
MaintenanceEndsAt = activeWindow.EndsAt,
MaintenanceReason = activeWindow.Reason
};
}
return new MaintenanceCheckResult
{
IsInMaintenance = false
};
}
/// <summary>
/// Adds a quiet hours schedule (for configuration/testing).
/// </summary>
public void AddSchedule(NotifyQuietHoursSchedule schedule)
{
ArgumentNullException.ThrowIfNull(schedule);
_schedules.Add(schedule);
}
/// <summary>
/// Adds a maintenance window (for configuration/testing).
/// </summary>
public void AddMaintenanceWindow(NotifyMaintenanceWindow window)
{
ArgumentNullException.ThrowIfNull(window);
_maintenanceWindows.Add(window);
}
private bool IsInSchedule(NotifyQuietHoursSchedule schedule, DateTimeOffset now, out DateTimeOffset? endsAt)
{
endsAt = null;
try
{
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZone);
var localNow = TimeZoneInfo.ConvertTime(now, timeZone);
var cron = CronExpression.Parse(schedule.CronExpression);
// Look back for the most recent occurrence
var searchStart = localNow.AddDays(-1);
var lastOccurrence = cron.GetNextOccurrence(searchStart.DateTime, timeZone, inclusive: true);
if (lastOccurrence.HasValue)
{
var occurrenceOffset = new DateTimeOffset(lastOccurrence.Value, timeZone.GetUtcOffset(lastOccurrence.Value));
var windowEnd = occurrenceOffset.Add(schedule.Duration);
if (now >= occurrenceOffset && now < windowEnd)
{
endsAt = windowEnd;
return true;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to evaluate quiet hours schedule {ScheduleId} for tenant {TenantId}",
schedule.ScheduleId, schedule.TenantId);
}
return false;
}
}

View File

@@ -1,41 +0,0 @@
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Throttling service for rate-limiting notifications.
/// </summary>
public interface INotifyThrottler
{
/// <summary>
/// Checks if a notification should be throttled based on the key and window.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="throttleKey">The unique key for throttling (e.g., action + correlation key).</param>
/// <param name="window">The throttle window duration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if throttled (should not send), false if allowed.</returns>
Task<bool> IsThrottledAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a notification as sent, establishing the throttle marker.
/// </summary>
Task RecordSentAsync(
string tenantId,
string throttleKey,
TimeSpan window,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a throttle check with additional context.
/// </summary>
public sealed record ThrottleCheckResult
{
public required bool IsThrottled { get; init; }
public DateTimeOffset? ThrottledUntil { get; init; }
public DateTimeOffset? LastSentAt { get; init; }
public int SuppressedCount { get; init; }
}

View File

@@ -1,44 +0,0 @@
namespace StellaOps.Notifier.Worker.Correlation;
/// <summary>
/// Evaluates whether notifications should be suppressed due to quiet hours or maintenance windows.
/// </summary>
public interface IQuietHoursEvaluator
{
/// <summary>
/// Checks if the current time falls within a quiet hours period for the tenant.
/// </summary>
Task<QuietHoursCheckResult> IsInQuietHoursAsync(
string tenantId,
string? channelId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if notifications should be suppressed due to an active maintenance window.
/// </summary>
Task<MaintenanceCheckResult> IsInMaintenanceAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a quiet hours check.
/// </summary>
public sealed record QuietHoursCheckResult
{
public required bool IsInQuietHours { get; init; }
public string? QuietHoursScheduleId { get; init; }
public DateTimeOffset? QuietHoursEndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Result of a maintenance window check.
/// </summary>
public sealed record MaintenanceCheckResult
{
public required bool IsInMaintenance { get; init; }
public string? MaintenanceWindowId { get; init; }
public DateTimeOffset? MaintenanceEndsAt { get; init; }
public string? MaintenanceReason { get; init; }
}

View File

@@ -1,456 +0,0 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notifier.Worker.Observability;
/// <summary>
/// Manages data retention policies for notifications and related data.
/// </summary>
public interface IRetentionPolicyService
{
/// <summary>
/// Gets all retention policies for a tenant.
/// </summary>
Task<IReadOnlyList<RetentionPolicy>> GetPoliciesAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific retention policy.
/// </summary>
Task<RetentionPolicy?> GetPolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a retention policy.
/// </summary>
Task<RetentionPolicy> UpsertPolicyAsync(RetentionPolicy policy, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a retention policy.
/// </summary>
Task<bool> DeletePolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Applies retention policies and purges old data.
/// </summary>
Task<RetentionResult> ApplyAsync(string? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Gets retention statistics.
/// </summary>
Task<RetentionStats> GetStatsAsync(string? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Previews what would be deleted by retention policies.
/// </summary>
Task<RetentionPreview> PreviewAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// A data retention policy.
/// </summary>
public sealed record RetentionPolicy
{
public required string PolicyId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required RetentionDataType DataType { get; init; }
public required TimeSpan RetentionPeriod { get; init; }
public RetentionAction Action { get; init; } = RetentionAction.Delete;
public string? ArchiveDestination { get; init; }
public bool Enabled { get; init; } = true;
public IReadOnlyList<string>? ChannelTypes { get; init; }
public IReadOnlyList<string>? EventKinds { get; init; }
public int? MinimumCount { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? LastAppliedAt { get; init; }
}
/// <summary>
/// Type of data subject to retention.
/// </summary>
public enum RetentionDataType
{
Deliveries,
DeadLetters,
Incidents,
AuditLogs,
Metrics,
Templates,
EscalationHistory,
DigestHistory,
InboxNotifications
}
/// <summary>
/// Action to take when retention period expires.
/// </summary>
public enum RetentionAction
{
Delete,
Archive,
Anonymize
}
/// <summary>
/// Result of applying retention policies.
/// </summary>
public sealed record RetentionResult
{
public DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public int PoliciesApplied { get; init; }
public int TotalDeleted { get; init; }
public int TotalArchived { get; init; }
public int TotalAnonymized { get; init; }
public TimeSpan Duration { get; init; }
public IReadOnlyList<RetentionPolicyResult> PolicyResults { get; init; } = [];
public IReadOnlyList<string> Errors { get; init; } = [];
}
/// <summary>
/// Result of applying a single retention policy.
/// </summary>
public sealed record RetentionPolicyResult
{
public required string PolicyId { get; init; }
public required string PolicyName { get; init; }
public required RetentionDataType DataType { get; init; }
public int AffectedCount { get; init; }
public RetentionAction ActionTaken { get; init; }
public bool Success { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Statistics about retention.
/// </summary>
public sealed record RetentionStats
{
public DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public int TotalPolicies { get; init; }
public int EnabledPolicies { get; init; }
public int DisabledPolicies { get; init; }
public long TotalDeletedAllTime { get; init; }
public long TotalArchivedAllTime { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public DateTimeOffset? NextScheduledRun { get; init; }
public IReadOnlyDictionary<RetentionDataType, DataTypeStats> ByDataType { get; init; } = new Dictionary<RetentionDataType, DataTypeStats>();
}
/// <summary>
/// Statistics for a specific data type.
/// </summary>
public sealed record DataTypeStats
{
public required RetentionDataType DataType { get; init; }
public long CurrentCount { get; init; }
public DateTimeOffset? OldestRecord { get; init; }
public long DeletedCount { get; init; }
public long ArchivedCount { get; init; }
}
/// <summary>
/// Preview of what retention would delete.
/// </summary>
public sealed record RetentionPreview
{
public DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public int TotalToDelete { get; init; }
public int TotalToArchive { get; init; }
public int TotalToAnonymize { get; init; }
public IReadOnlyList<RetentionPreviewItem> Items { get; init; } = [];
}
/// <summary>
/// Preview item for a single policy.
/// </summary>
public sealed record RetentionPreviewItem
{
public required string PolicyId { get; init; }
public required string PolicyName { get; init; }
public required RetentionDataType DataType { get; init; }
public int AffectedCount { get; init; }
public RetentionAction Action { get; init; }
public DateTimeOffset? OldestAffected { get; init; }
public DateTimeOffset? NewestAffected { get; init; }
}
/// <summary>
/// Options for retention service.
/// </summary>
public sealed class RetentionOptions
{
public const string SectionName = "Notifier:Observability:Retention";
public bool Enabled { get; set; } = true;
public TimeSpan DefaultRetentionPeriod { get; set; } = TimeSpan.FromDays(90);
public TimeSpan MinimumRetentionPeriod { get; set; } = TimeSpan.FromDays(1);
public TimeSpan MaximumRetentionPeriod { get; set; } = TimeSpan.FromDays(365 * 7);
public bool AutoRun { get; set; } = true;
public TimeSpan RunInterval { get; set; } = TimeSpan.FromHours(24);
public TimeSpan RunTime { get; set; } = TimeSpan.FromHours(3);
public int BatchSize { get; set; } = 1000;
public bool DryRunByDefault { get; set; }
}
/// <summary>
/// In-memory implementation of retention policy service.
/// </summary>
public sealed class InMemoryRetentionPolicyService : IRetentionPolicyService
{
private readonly ConcurrentDictionary<string, List<RetentionPolicy>> _policies = new();
private readonly ConcurrentDictionary<string, RetentionStats> _stats = new();
private readonly RetentionOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryRetentionPolicyService> _logger;
public InMemoryRetentionPolicyService(
IOptions<RetentionOptions> options,
TimeProvider timeProvider,
ILogger<InMemoryRetentionPolicyService> logger)
{
_options = options?.Value ?? new RetentionOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<IReadOnlyList<RetentionPolicy>> GetPoliciesAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies))
return Task.FromResult<IReadOnlyList<RetentionPolicy>>([]);
return Task.FromResult<IReadOnlyList<RetentionPolicy>>(policies.ToList());
}
public Task<RetentionPolicy?> GetPolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies))
return Task.FromResult<RetentionPolicy?>(null);
return Task.FromResult(policies.FirstOrDefault(p => p.PolicyId == policyId));
}
public Task<RetentionPolicy> UpsertPolicyAsync(RetentionPolicy policy, CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var list = _policies.GetOrAdd(policy.TenantId, _ => []);
lock (list)
{
var index = list.FindIndex(p => p.PolicyId == policy.PolicyId);
var updated = policy with { UpdatedAt = now, CreatedAt = index < 0 ? now : list[index].CreatedAt };
if (index >= 0) list[index] = updated;
else list.Add(updated);
_logger.LogInformation("Upserted retention policy {PolicyId} for tenant {TenantId}", policy.PolicyId, policy.TenantId);
return Task.FromResult(updated);
}
}
public Task<bool> DeletePolicyAsync(string tenantId, string policyId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies)) return Task.FromResult(false);
lock (policies)
{
var removed = policies.RemoveAll(p => p.PolicyId == policyId) > 0;
if (removed) _logger.LogInformation("Deleted retention policy {PolicyId} for tenant {TenantId}", policyId, tenantId);
return Task.FromResult(removed);
}
}
public Task<RetentionResult> ApplyAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
var policyResults = new List<RetentionPolicyResult>();
var errors = new List<string>();
var totalDeleted = 0;
var totalArchived = 0;
var totalAnonymized = 0;
var tenantsToProcess = tenantId is not null ? [tenantId] : _policies.Keys.ToList();
foreach (var t in tenantsToProcess)
{
if (!_policies.TryGetValue(t, out var policies)) continue;
foreach (var policy in policies.Where(p => p.Enabled))
{
try
{
var affectedCount = SimulateRetention(policy);
var result = new RetentionPolicyResult
{
PolicyId = policy.PolicyId,
PolicyName = policy.Name,
DataType = policy.DataType,
AffectedCount = affectedCount,
ActionTaken = policy.Action,
Success = true
};
policyResults.Add(result);
switch (policy.Action)
{
case RetentionAction.Delete: totalDeleted += affectedCount; break;
case RetentionAction.Archive: totalArchived += affectedCount; break;
case RetentionAction.Anonymize: totalAnonymized += affectedCount; break;
}
// Update last applied time
lock (policies)
{
var idx = policies.FindIndex(p => p.PolicyId == policy.PolicyId);
if (idx >= 0) policies[idx] = policy with { LastAppliedAt = _timeProvider.GetUtcNow() };
}
}
catch (Exception ex)
{
errors.Add($"Policy {policy.PolicyId}: {ex.Message}");
policyResults.Add(new RetentionPolicyResult
{
PolicyId = policy.PolicyId,
PolicyName = policy.Name,
DataType = policy.DataType,
Success = false,
Error = ex.Message
});
}
}
}
var endTime = _timeProvider.GetUtcNow();
_logger.LogInformation("Applied retention policies: {Deleted} deleted, {Archived} archived, {Anonymized} anonymized", totalDeleted, totalArchived, totalAnonymized);
return Task.FromResult(new RetentionResult
{
Timestamp = endTime,
TenantId = tenantId,
PoliciesApplied = policyResults.Count(r => r.Success),
TotalDeleted = totalDeleted,
TotalArchived = totalArchived,
TotalAnonymized = totalAnonymized,
Duration = endTime - startTime,
PolicyResults = policyResults,
Errors = errors
});
}
public Task<RetentionStats> GetStatsAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var allPolicies = tenantId is not null
? (_policies.TryGetValue(tenantId, out var p) ? p : [])
: _policies.Values.SelectMany(v => v).ToList();
var byDataType = Enum.GetValues<RetentionDataType>()
.ToDictionary(dt => dt, dt => new DataTypeStats { DataType = dt, CurrentCount = 0, DeletedCount = 0, ArchivedCount = 0 });
return Task.FromResult(new RetentionStats
{
Timestamp = _timeProvider.GetUtcNow(),
TenantId = tenantId,
TotalPolicies = allPolicies.Count,
EnabledPolicies = allPolicies.Count(p => p.Enabled),
DisabledPolicies = allPolicies.Count(p => !p.Enabled),
LastRunAt = allPolicies.Max(p => p.LastAppliedAt),
ByDataType = byDataType
});
}
public Task<RetentionPreview> PreviewAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (!_policies.TryGetValue(tenantId, out var policies))
return Task.FromResult(new RetentionPreview { Timestamp = _timeProvider.GetUtcNow(), TenantId = tenantId });
var items = policies.Where(p => p.Enabled).Select(p => new RetentionPreviewItem
{
PolicyId = p.PolicyId,
PolicyName = p.Name,
DataType = p.DataType,
AffectedCount = SimulateRetention(p),
Action = p.Action
}).ToList();
return Task.FromResult(new RetentionPreview
{
Timestamp = _timeProvider.GetUtcNow(),
TenantId = tenantId,
TotalToDelete = items.Where(i => i.Action == RetentionAction.Delete).Sum(i => i.AffectedCount),
TotalToArchive = items.Where(i => i.Action == RetentionAction.Archive).Sum(i => i.AffectedCount),
TotalToAnonymize = items.Where(i => i.Action == RetentionAction.Anonymize).Sum(i => i.AffectedCount),
Items = items
});
}
private int SimulateRetention(RetentionPolicy policy)
{
// In production, this would query actual data stores
// For simulation, return a random count based on retention period
var daysFactor = (int)policy.RetentionPeriod.TotalDays;
return Math.Max(0, 100 - daysFactor);
}
}
/// <summary>
/// Background service that runs retention policies on schedule.
/// </summary>
public sealed class RetentionPolicyRunner : BackgroundService
{
private readonly IRetentionPolicyService _retentionService;
private readonly RetentionOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RetentionPolicyRunner> _logger;
public RetentionPolicyRunner(
IRetentionPolicyService retentionService,
IOptions<RetentionOptions> options,
TimeProvider timeProvider,
ILogger<RetentionPolicyRunner> logger)
{
_retentionService = retentionService ?? throw new ArgumentNullException(nameof(retentionService));
_options = options?.Value ?? new RetentionOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled || !_options.AutoRun)
{
_logger.LogInformation("Retention policy runner is disabled");
return;
}
_logger.LogInformation("Retention policy runner started with interval {Interval}", _options.RunInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = _timeProvider.GetUtcNow();
var nextRun = now.Date.Add(_options.RunTime);
if (nextRun <= now) nextRun = nextRun.AddDays(1);
var delay = nextRun - now;
if (delay > _options.RunInterval) delay = _options.RunInterval;
await Task.Delay(delay, stoppingToken);
_logger.LogInformation("Running scheduled retention policy application");
var result = await _retentionService.ApplyAsync(cancellationToken: stoppingToken);
_logger.LogInformation("Retention completed: {Deleted} deleted, {Archived} archived in {Duration}ms",
result.TotalDeleted, result.TotalArchived, result.Duration.TotalMilliseconds);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running retention policies");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
}

View File

@@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="Cronos" Version="0.9.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />