Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
1102 lines
32 KiB
C#
1102 lines
32 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace StellaOps.Notifier.Worker.Observability;
|
|
|
|
/// <summary>
|
|
/// Service for managing data retention policies.
|
|
/// Handles cleanup of old notifications, delivery logs, escalations, and metrics.
|
|
/// </summary>
|
|
public interface IRetentionPolicyService
|
|
{
|
|
/// <summary>
|
|
/// Registers a retention policy.
|
|
/// </summary>
|
|
Task RegisterPolicyAsync(RetentionPolicy policy, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Updates an existing retention policy.
|
|
/// </summary>
|
|
Task UpdatePolicyAsync(string policyId, RetentionPolicy policy, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets a retention policy by ID.
|
|
/// </summary>
|
|
Task<RetentionPolicy?> GetPolicyAsync(string policyId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Lists all retention policies.
|
|
/// </summary>
|
|
Task<IReadOnlyList<RetentionPolicy>> ListPoliciesAsync(string? tenantId = null, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Deletes a retention policy.
|
|
/// </summary>
|
|
Task DeletePolicyAsync(string policyId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Executes retention policies, returning cleanup results.
|
|
/// </summary>
|
|
Task<RetentionExecutionResult> ExecuteRetentionAsync(string? policyId = null, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets the next scheduled execution time for a policy.
|
|
/// </summary>
|
|
Task<DateTimeOffset?> GetNextExecutionAsync(string policyId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets execution history for a policy.
|
|
/// </summary>
|
|
Task<IReadOnlyList<RetentionExecutionRecord>> GetExecutionHistoryAsync(
|
|
string policyId,
|
|
int limit = 100,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Previews what would be deleted by a policy without actually deleting.
|
|
/// </summary>
|
|
Task<RetentionPreview> PreviewRetentionAsync(string policyId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Registers a cleanup handler for a specific data type.
|
|
/// </summary>
|
|
void RegisterHandler(string dataType, IRetentionHandler handler);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handler for cleaning up specific data types.
|
|
/// </summary>
|
|
public interface IRetentionHandler
|
|
{
|
|
/// <summary>
|
|
/// The data type this handler manages.
|
|
/// </summary>
|
|
string DataType { get; }
|
|
|
|
/// <summary>
|
|
/// Counts items that would be deleted.
|
|
/// </summary>
|
|
Task<long> CountAsync(RetentionQuery query, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Deletes items matching the query.
|
|
/// </summary>
|
|
Task<long> DeleteAsync(RetentionQuery query, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Archives items matching the query (if supported).
|
|
/// </summary>
|
|
Task<long> ArchiveAsync(RetentionQuery query, string archiveLocation, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retention policy definition.
|
|
/// </summary>
|
|
public sealed record RetentionPolicy
|
|
{
|
|
/// <summary>
|
|
/// Unique policy identifier.
|
|
/// </summary>
|
|
public required string Id { get; init; }
|
|
|
|
/// <summary>
|
|
/// Human-readable name.
|
|
/// </summary>
|
|
public required string Name { get; init; }
|
|
|
|
/// <summary>
|
|
/// Description of what the policy does.
|
|
/// </summary>
|
|
public string? Description { get; init; }
|
|
|
|
/// <summary>
|
|
/// Tenant ID this policy applies to (null for global).
|
|
/// </summary>
|
|
public string? TenantId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Data type to clean up.
|
|
/// </summary>
|
|
public required RetentionDataType DataType { get; init; }
|
|
|
|
/// <summary>
|
|
/// Retention period - data older than this is eligible for cleanup.
|
|
/// </summary>
|
|
public required TimeSpan RetentionPeriod { get; init; }
|
|
|
|
/// <summary>
|
|
/// Action to take on expired data.
|
|
/// </summary>
|
|
public RetentionAction Action { get; init; } = RetentionAction.Delete;
|
|
|
|
/// <summary>
|
|
/// Archive location (for Archive action).
|
|
/// </summary>
|
|
public string? ArchiveLocation { get; init; }
|
|
|
|
/// <summary>
|
|
/// Schedule for policy execution (cron expression).
|
|
/// </summary>
|
|
public string? Schedule { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether the policy is enabled.
|
|
/// </summary>
|
|
public bool Enabled { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Additional filters for targeting specific data.
|
|
/// </summary>
|
|
public RetentionFilters Filters { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Maximum items to process per execution (0 = unlimited).
|
|
/// </summary>
|
|
public int BatchSize { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether to use soft delete (mark as deleted vs hard delete).
|
|
/// </summary>
|
|
public bool SoftDelete { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the policy was created.
|
|
/// </summary>
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Who created the policy.
|
|
/// </summary>
|
|
public string? CreatedBy { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the policy was last modified.
|
|
/// </summary>
|
|
public DateTimeOffset? ModifiedAt { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Types of data that can have retention policies.
|
|
/// </summary>
|
|
public enum RetentionDataType
|
|
{
|
|
/// <summary>
|
|
/// Notification delivery logs.
|
|
/// </summary>
|
|
DeliveryLogs,
|
|
|
|
/// <summary>
|
|
/// Escalation records.
|
|
/// </summary>
|
|
Escalations,
|
|
|
|
/// <summary>
|
|
/// Storm/correlation events.
|
|
/// </summary>
|
|
StormEvents,
|
|
|
|
/// <summary>
|
|
/// Dead-letter entries.
|
|
/// </summary>
|
|
DeadLetters,
|
|
|
|
/// <summary>
|
|
/// Audit logs.
|
|
/// </summary>
|
|
AuditLogs,
|
|
|
|
/// <summary>
|
|
/// Metrics data.
|
|
/// </summary>
|
|
Metrics,
|
|
|
|
/// <summary>
|
|
/// Trace spans.
|
|
/// </summary>
|
|
Traces,
|
|
|
|
/// <summary>
|
|
/// Chaos experiment records.
|
|
/// </summary>
|
|
ChaosExperiments,
|
|
|
|
/// <summary>
|
|
/// Tenant isolation violations.
|
|
/// </summary>
|
|
IsolationViolations,
|
|
|
|
/// <summary>
|
|
/// Webhook delivery logs.
|
|
/// </summary>
|
|
WebhookLogs,
|
|
|
|
/// <summary>
|
|
/// Template render cache.
|
|
/// </summary>
|
|
TemplateCache
|
|
}
|
|
|
|
/// <summary>
|
|
/// Actions to take on expired data.
|
|
/// </summary>
|
|
public enum RetentionAction
|
|
{
|
|
/// <summary>
|
|
/// Delete the data permanently.
|
|
/// </summary>
|
|
Delete,
|
|
|
|
/// <summary>
|
|
/// Archive the data to cold storage.
|
|
/// </summary>
|
|
Archive,
|
|
|
|
/// <summary>
|
|
/// Compress and keep in place.
|
|
/// </summary>
|
|
Compress,
|
|
|
|
/// <summary>
|
|
/// Mark for manual review.
|
|
/// </summary>
|
|
FlagForReview
|
|
}
|
|
|
|
/// <summary>
|
|
/// Additional filters for retention policies.
|
|
/// </summary>
|
|
public sealed record RetentionFilters
|
|
{
|
|
/// <summary>
|
|
/// Filter by channel types.
|
|
/// </summary>
|
|
public IReadOnlyList<string> ChannelTypes { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Filter by delivery status.
|
|
/// </summary>
|
|
public IReadOnlyList<string> Statuses { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Filter by severity levels.
|
|
/// </summary>
|
|
public IReadOnlyList<string> Severities { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Exclude items matching these tags.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> ExcludeTags { get; init; } = new Dictionary<string, string>();
|
|
|
|
/// <summary>
|
|
/// Only include items matching these tags.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string> IncludeTags { get; init; } = new Dictionary<string, string>();
|
|
|
|
/// <summary>
|
|
/// Custom filter expression.
|
|
/// </summary>
|
|
public string? CustomFilter { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query for retention operations.
|
|
/// </summary>
|
|
public sealed record RetentionQuery
|
|
{
|
|
/// <summary>
|
|
/// Tenant ID to query.
|
|
/// </summary>
|
|
public string? TenantId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Data type to query.
|
|
/// </summary>
|
|
public required RetentionDataType DataType { get; init; }
|
|
|
|
/// <summary>
|
|
/// Cutoff date - data before this date is eligible.
|
|
/// </summary>
|
|
public required DateTimeOffset CutoffDate { get; init; }
|
|
|
|
/// <summary>
|
|
/// Additional filters.
|
|
/// </summary>
|
|
public RetentionFilters Filters { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Maximum items to return/delete.
|
|
/// </summary>
|
|
public int? Limit { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether to use soft delete.
|
|
/// </summary>
|
|
public bool SoftDelete { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of retention policy execution.
|
|
/// </summary>
|
|
public sealed record RetentionExecutionResult
|
|
{
|
|
/// <summary>
|
|
/// Unique execution identifier.
|
|
/// </summary>
|
|
public required string ExecutionId { get; init; }
|
|
|
|
/// <summary>
|
|
/// When execution started.
|
|
/// </summary>
|
|
public required DateTimeOffset StartedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// When execution completed.
|
|
/// </summary>
|
|
public required DateTimeOffset CompletedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Policies that were executed.
|
|
/// </summary>
|
|
public IReadOnlyList<string> PoliciesExecuted { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Total items processed.
|
|
/// </summary>
|
|
public required long TotalProcessed { get; init; }
|
|
|
|
/// <summary>
|
|
/// Total items deleted.
|
|
/// </summary>
|
|
public required long TotalDeleted { get; init; }
|
|
|
|
/// <summary>
|
|
/// Total items archived.
|
|
/// </summary>
|
|
public required long TotalArchived { get; init; }
|
|
|
|
/// <summary>
|
|
/// Results by policy.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, PolicyExecutionResult> ByPolicy { get; init; } = new Dictionary<string, PolicyExecutionResult>();
|
|
|
|
/// <summary>
|
|
/// Errors encountered during execution.
|
|
/// </summary>
|
|
public IReadOnlyList<RetentionError> Errors { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Whether execution completed successfully.
|
|
/// </summary>
|
|
public bool Success => Errors.Count == 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result for a single policy execution.
|
|
/// </summary>
|
|
public sealed record PolicyExecutionResult
|
|
{
|
|
/// <summary>
|
|
/// Policy ID.
|
|
/// </summary>
|
|
public required string PolicyId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Items processed.
|
|
/// </summary>
|
|
public required long Processed { get; init; }
|
|
|
|
/// <summary>
|
|
/// Items deleted.
|
|
/// </summary>
|
|
public required long Deleted { get; init; }
|
|
|
|
/// <summary>
|
|
/// Items archived.
|
|
/// </summary>
|
|
public required long Archived { get; init; }
|
|
|
|
/// <summary>
|
|
/// Duration of execution.
|
|
/// </summary>
|
|
public required TimeSpan Duration { get; init; }
|
|
|
|
/// <summary>
|
|
/// Error if execution failed.
|
|
/// </summary>
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Error during retention execution.
|
|
/// </summary>
|
|
public sealed record RetentionError
|
|
{
|
|
/// <summary>
|
|
/// Policy that caused the error.
|
|
/// </summary>
|
|
public string? PolicyId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Error message.
|
|
/// </summary>
|
|
public required string Message { get; init; }
|
|
|
|
/// <summary>
|
|
/// Exception type if applicable.
|
|
/// </summary>
|
|
public string? ExceptionType { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the error occurred.
|
|
/// </summary>
|
|
public required DateTimeOffset Timestamp { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Historical record of retention execution.
|
|
/// </summary>
|
|
public sealed record RetentionExecutionRecord
|
|
{
|
|
/// <summary>
|
|
/// Execution identifier.
|
|
/// </summary>
|
|
public required string ExecutionId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Policy that was executed.
|
|
/// </summary>
|
|
public required string PolicyId { get; init; }
|
|
|
|
/// <summary>
|
|
/// When execution started.
|
|
/// </summary>
|
|
public required DateTimeOffset StartedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// When execution completed.
|
|
/// </summary>
|
|
public required DateTimeOffset CompletedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Items deleted.
|
|
/// </summary>
|
|
public required long Deleted { get; init; }
|
|
|
|
/// <summary>
|
|
/// Items archived.
|
|
/// </summary>
|
|
public required long Archived { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether execution succeeded.
|
|
/// </summary>
|
|
public required bool Success { get; init; }
|
|
|
|
/// <summary>
|
|
/// Error message if failed.
|
|
/// </summary>
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Preview of what retention would delete.
|
|
/// </summary>
|
|
public sealed record RetentionPreview
|
|
{
|
|
/// <summary>
|
|
/// Policy ID being previewed.
|
|
/// </summary>
|
|
public required string PolicyId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Cutoff date that would be used.
|
|
/// </summary>
|
|
public required DateTimeOffset CutoffDate { get; init; }
|
|
|
|
/// <summary>
|
|
/// Total items that would be affected.
|
|
/// </summary>
|
|
public required long TotalAffected { get; init; }
|
|
|
|
/// <summary>
|
|
/// Breakdown by category.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, long> ByCategory { get; init; } = new Dictionary<string, long>();
|
|
|
|
/// <summary>
|
|
/// Sample of items that would be affected.
|
|
/// </summary>
|
|
public IReadOnlyList<RetentionPreviewItem> SampleItems { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sample item in retention preview.
|
|
/// </summary>
|
|
public sealed record RetentionPreviewItem
|
|
{
|
|
/// <summary>
|
|
/// Item identifier.
|
|
/// </summary>
|
|
public required string Id { get; init; }
|
|
|
|
/// <summary>
|
|
/// Item type.
|
|
/// </summary>
|
|
public required string Type { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the item was created.
|
|
/// </summary>
|
|
public required DateTimeOffset CreatedAt { get; init; }
|
|
|
|
/// <summary>
|
|
/// Summary of the item.
|
|
/// </summary>
|
|
public string? Summary { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options for retention policy service.
|
|
/// </summary>
|
|
public sealed class RetentionPolicyOptions
|
|
{
|
|
public const string SectionName = "Notifier:Observability:Retention";
|
|
|
|
/// <summary>
|
|
/// Whether retention is enabled.
|
|
/// </summary>
|
|
public bool Enabled { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Default retention period for data without explicit policy.
|
|
/// </summary>
|
|
public TimeSpan DefaultRetentionPeriod { get; set; } = TimeSpan.FromDays(90);
|
|
|
|
/// <summary>
|
|
/// Maximum retention period allowed.
|
|
/// </summary>
|
|
public TimeSpan MaxRetentionPeriod { get; set; } = TimeSpan.FromDays(365 * 7);
|
|
|
|
/// <summary>
|
|
/// Minimum retention period allowed.
|
|
/// </summary>
|
|
public TimeSpan MinRetentionPeriod { get; set; } = TimeSpan.FromDays(1);
|
|
|
|
/// <summary>
|
|
/// Default batch size for cleanup operations.
|
|
/// </summary>
|
|
public int DefaultBatchSize { get; set; } = 1000;
|
|
|
|
/// <summary>
|
|
/// Maximum concurrent cleanup operations.
|
|
/// </summary>
|
|
public int MaxConcurrentOperations { get; set; } = 4;
|
|
|
|
/// <summary>
|
|
/// How long to keep execution history.
|
|
/// </summary>
|
|
public TimeSpan ExecutionHistoryRetention { get; set; } = TimeSpan.FromDays(30);
|
|
|
|
/// <summary>
|
|
/// Default data type retention periods.
|
|
/// </summary>
|
|
public Dictionary<string, TimeSpan> DefaultPeriods { get; set; } = new()
|
|
{
|
|
["DeliveryLogs"] = TimeSpan.FromDays(30),
|
|
["Escalations"] = TimeSpan.FromDays(90),
|
|
["StormEvents"] = TimeSpan.FromDays(14),
|
|
["DeadLetters"] = TimeSpan.FromDays(7),
|
|
["AuditLogs"] = TimeSpan.FromDays(365),
|
|
["Metrics"] = TimeSpan.FromDays(30),
|
|
["Traces"] = TimeSpan.FromDays(7),
|
|
["ChaosExperiments"] = TimeSpan.FromDays(7),
|
|
["IsolationViolations"] = TimeSpan.FromDays(90),
|
|
["WebhookLogs"] = TimeSpan.FromDays(14),
|
|
["TemplateCache"] = TimeSpan.FromDays(1)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of retention policy service.
|
|
/// </summary>
|
|
public sealed class InMemoryRetentionPolicyService : IRetentionPolicyService
|
|
{
|
|
private readonly ConcurrentDictionary<string, RetentionPolicy> _policies = new();
|
|
private readonly ConcurrentDictionary<string, List<RetentionExecutionRecord>> _history = new();
|
|
private readonly ConcurrentDictionary<string, IRetentionHandler> _handlers = new();
|
|
private readonly RetentionPolicyOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<InMemoryRetentionPolicyService> _logger;
|
|
|
|
public InMemoryRetentionPolicyService(
|
|
IOptions<RetentionPolicyOptions> options,
|
|
TimeProvider timeProvider,
|
|
ILogger<InMemoryRetentionPolicyService> logger)
|
|
{
|
|
_options = options?.Value ?? new RetentionPolicyOptions();
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public Task RegisterPolicyAsync(RetentionPolicy policy, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(policy);
|
|
ValidatePolicy(policy);
|
|
|
|
var policyWithTimestamp = policy with
|
|
{
|
|
CreatedAt = _timeProvider.GetUtcNow()
|
|
};
|
|
|
|
if (!_policies.TryAdd(policy.Id, policyWithTimestamp))
|
|
{
|
|
throw new InvalidOperationException($"Policy '{policy.Id}' already exists");
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Registered retention policy {PolicyId}: {DataType} with {Retention} retention",
|
|
policy.Id,
|
|
policy.DataType,
|
|
policy.RetentionPeriod);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task UpdatePolicyAsync(string policyId, RetentionPolicy policy, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(policy);
|
|
ValidatePolicy(policy);
|
|
|
|
if (!_policies.TryGetValue(policyId, out var existing))
|
|
{
|
|
throw new KeyNotFoundException($"Policy '{policyId}' not found");
|
|
}
|
|
|
|
var updated = policy with
|
|
{
|
|
Id = policyId,
|
|
CreatedAt = existing.CreatedAt,
|
|
ModifiedAt = _timeProvider.GetUtcNow()
|
|
};
|
|
|
|
_policies[policyId] = updated;
|
|
|
|
_logger.LogInformation("Updated retention policy {PolicyId}", policyId);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<RetentionPolicy?> GetPolicyAsync(string policyId, CancellationToken ct = default)
|
|
{
|
|
_policies.TryGetValue(policyId, out var policy);
|
|
return Task.FromResult(policy);
|
|
}
|
|
|
|
public Task<IReadOnlyList<RetentionPolicy>> ListPoliciesAsync(string? tenantId = null, CancellationToken ct = default)
|
|
{
|
|
var query = _policies.Values.AsEnumerable();
|
|
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
{
|
|
query = query.Where(p => p.TenantId == tenantId || p.TenantId == null);
|
|
}
|
|
|
|
var result = query.OrderBy(p => p.Name).ToList();
|
|
return Task.FromResult<IReadOnlyList<RetentionPolicy>>(result);
|
|
}
|
|
|
|
public Task DeletePolicyAsync(string policyId, CancellationToken ct = default)
|
|
{
|
|
if (_policies.TryRemove(policyId, out _))
|
|
{
|
|
_logger.LogInformation("Deleted retention policy {PolicyId}", policyId);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task<RetentionExecutionResult> ExecuteRetentionAsync(string? policyId = null, CancellationToken ct = default)
|
|
{
|
|
if (!_options.Enabled)
|
|
{
|
|
return new RetentionExecutionResult
|
|
{
|
|
ExecutionId = $"exec-{Guid.NewGuid():N}",
|
|
StartedAt = _timeProvider.GetUtcNow(),
|
|
CompletedAt = _timeProvider.GetUtcNow(),
|
|
TotalProcessed = 0,
|
|
TotalDeleted = 0,
|
|
TotalArchived = 0,
|
|
Errors = [new RetentionError
|
|
{
|
|
Message = "Retention is disabled",
|
|
Timestamp = _timeProvider.GetUtcNow()
|
|
}]
|
|
};
|
|
}
|
|
|
|
var startedAt = _timeProvider.GetUtcNow();
|
|
var executionId = $"exec-{Guid.NewGuid():N}";
|
|
var byPolicy = new Dictionary<string, PolicyExecutionResult>();
|
|
var errors = new List<RetentionError>();
|
|
long totalDeleted = 0;
|
|
long totalArchived = 0;
|
|
|
|
var policiesToExecute = string.IsNullOrEmpty(policyId)
|
|
? _policies.Values.Where(p => p.Enabled).ToList()
|
|
: _policies.Values.Where(p => p.Id == policyId && p.Enabled).ToList();
|
|
|
|
foreach (var policy in policiesToExecute)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var policyStart = _timeProvider.GetUtcNow();
|
|
try
|
|
{
|
|
var result = await ExecutePolicyAsync(policy, ct);
|
|
totalDeleted += result.Deleted;
|
|
totalArchived += result.Archived;
|
|
byPolicy[policy.Id] = result;
|
|
|
|
// Record execution
|
|
RecordExecution(policy.Id, executionId, policyStart, result, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error executing retention policy {PolicyId}", policy.Id);
|
|
|
|
errors.Add(new RetentionError
|
|
{
|
|
PolicyId = policy.Id,
|
|
Message = ex.Message,
|
|
ExceptionType = ex.GetType().Name,
|
|
Timestamp = _timeProvider.GetUtcNow()
|
|
});
|
|
|
|
byPolicy[policy.Id] = new PolicyExecutionResult
|
|
{
|
|
PolicyId = policy.Id,
|
|
Processed = 0,
|
|
Deleted = 0,
|
|
Archived = 0,
|
|
Duration = _timeProvider.GetUtcNow() - policyStart,
|
|
Error = ex.Message
|
|
};
|
|
|
|
RecordExecution(policy.Id, executionId, policyStart,
|
|
new PolicyExecutionResult
|
|
{
|
|
PolicyId = policy.Id,
|
|
Processed = 0,
|
|
Deleted = 0,
|
|
Archived = 0,
|
|
Duration = TimeSpan.Zero
|
|
},
|
|
ex.Message);
|
|
}
|
|
}
|
|
|
|
var completedAt = _timeProvider.GetUtcNow();
|
|
|
|
_logger.LogInformation(
|
|
"Retention execution {ExecutionId} completed: {Deleted} deleted, {Archived} archived, {Errors} errors",
|
|
executionId,
|
|
totalDeleted,
|
|
totalArchived,
|
|
errors.Count);
|
|
|
|
return new RetentionExecutionResult
|
|
{
|
|
ExecutionId = executionId,
|
|
StartedAt = startedAt,
|
|
CompletedAt = completedAt,
|
|
PoliciesExecuted = policiesToExecute.Select(p => p.Id).ToList(),
|
|
TotalProcessed = totalDeleted + totalArchived,
|
|
TotalDeleted = totalDeleted,
|
|
TotalArchived = totalArchived,
|
|
ByPolicy = byPolicy,
|
|
Errors = errors
|
|
};
|
|
}
|
|
|
|
private async Task<PolicyExecutionResult> ExecutePolicyAsync(RetentionPolicy policy, CancellationToken ct)
|
|
{
|
|
var start = _timeProvider.GetUtcNow();
|
|
var cutoff = start - policy.RetentionPeriod;
|
|
|
|
var query = new RetentionQuery
|
|
{
|
|
TenantId = policy.TenantId,
|
|
DataType = policy.DataType,
|
|
CutoffDate = cutoff,
|
|
Filters = policy.Filters,
|
|
Limit = policy.BatchSize > 0 ? policy.BatchSize : null,
|
|
SoftDelete = policy.SoftDelete
|
|
};
|
|
|
|
var dataTypeName = policy.DataType.ToString();
|
|
long deleted = 0;
|
|
long archived = 0;
|
|
|
|
if (_handlers.TryGetValue(dataTypeName, out var handler))
|
|
{
|
|
switch (policy.Action)
|
|
{
|
|
case RetentionAction.Delete:
|
|
deleted = await handler.DeleteAsync(query, ct);
|
|
break;
|
|
|
|
case RetentionAction.Archive:
|
|
if (!string.IsNullOrEmpty(policy.ArchiveLocation))
|
|
{
|
|
archived = await handler.ArchiveAsync(query, policy.ArchiveLocation, ct);
|
|
}
|
|
break;
|
|
|
|
case RetentionAction.FlagForReview:
|
|
// Just count, don't delete
|
|
deleted = 0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return new PolicyExecutionResult
|
|
{
|
|
PolicyId = policy.Id,
|
|
Processed = deleted + archived,
|
|
Deleted = deleted,
|
|
Archived = archived,
|
|
Duration = _timeProvider.GetUtcNow() - start
|
|
};
|
|
}
|
|
|
|
private void RecordExecution(string policyId, string executionId, DateTimeOffset startedAt, PolicyExecutionResult result, string? error)
|
|
{
|
|
var record = new RetentionExecutionRecord
|
|
{
|
|
ExecutionId = executionId,
|
|
PolicyId = policyId,
|
|
StartedAt = startedAt,
|
|
CompletedAt = startedAt + result.Duration,
|
|
Deleted = result.Deleted,
|
|
Archived = result.Archived,
|
|
Success = error == null,
|
|
Error = error
|
|
};
|
|
|
|
var history = _history.GetOrAdd(policyId, _ => []);
|
|
lock (history)
|
|
{
|
|
history.Add(record);
|
|
|
|
// Trim old history
|
|
var cutoff = _timeProvider.GetUtcNow() - _options.ExecutionHistoryRetention;
|
|
history.RemoveAll(r => r.CompletedAt < cutoff);
|
|
}
|
|
}
|
|
|
|
public Task<DateTimeOffset?> GetNextExecutionAsync(string policyId, CancellationToken ct = default)
|
|
{
|
|
if (!_policies.TryGetValue(policyId, out var policy))
|
|
{
|
|
return Task.FromResult<DateTimeOffset?>(null);
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(policy.Schedule))
|
|
{
|
|
return Task.FromResult<DateTimeOffset?>(null);
|
|
}
|
|
|
|
// Simple schedule parsing - in real implementation would use Cronos
|
|
// For now, return next hour as placeholder
|
|
var now = _timeProvider.GetUtcNow();
|
|
var next = now.AddHours(1);
|
|
next = new DateTimeOffset(next.Year, next.Month, next.Day, next.Hour, 0, 0, TimeSpan.Zero);
|
|
|
|
return Task.FromResult<DateTimeOffset?>(next);
|
|
}
|
|
|
|
public Task<IReadOnlyList<RetentionExecutionRecord>> GetExecutionHistoryAsync(
|
|
string policyId,
|
|
int limit = 100,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (_history.TryGetValue(policyId, out var history))
|
|
{
|
|
List<RetentionExecutionRecord> result;
|
|
lock (history)
|
|
{
|
|
result = history
|
|
.OrderByDescending(r => r.CompletedAt)
|
|
.Take(limit)
|
|
.ToList();
|
|
}
|
|
return Task.FromResult<IReadOnlyList<RetentionExecutionRecord>>(result);
|
|
}
|
|
|
|
return Task.FromResult<IReadOnlyList<RetentionExecutionRecord>>([]);
|
|
}
|
|
|
|
public async Task<RetentionPreview> PreviewRetentionAsync(string policyId, CancellationToken ct = default)
|
|
{
|
|
if (!_policies.TryGetValue(policyId, out var policy))
|
|
{
|
|
throw new KeyNotFoundException($"Policy '{policyId}' not found");
|
|
}
|
|
|
|
var cutoff = _timeProvider.GetUtcNow() - policy.RetentionPeriod;
|
|
var query = new RetentionQuery
|
|
{
|
|
TenantId = policy.TenantId,
|
|
DataType = policy.DataType,
|
|
CutoffDate = cutoff,
|
|
Filters = policy.Filters
|
|
};
|
|
|
|
long totalAffected = 0;
|
|
var dataTypeName = policy.DataType.ToString();
|
|
|
|
if (_handlers.TryGetValue(dataTypeName, out var handler))
|
|
{
|
|
totalAffected = await handler.CountAsync(query, ct);
|
|
}
|
|
|
|
return new RetentionPreview
|
|
{
|
|
PolicyId = policyId,
|
|
CutoffDate = cutoff,
|
|
TotalAffected = totalAffected,
|
|
ByCategory = new Dictionary<string, long>
|
|
{
|
|
[dataTypeName] = totalAffected
|
|
}
|
|
};
|
|
}
|
|
|
|
public void RegisterHandler(string dataType, IRetentionHandler handler)
|
|
{
|
|
_handlers[dataType] = handler;
|
|
_logger.LogDebug("Registered retention handler for {DataType}", dataType);
|
|
}
|
|
|
|
private void ValidatePolicy(RetentionPolicy policy)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(policy.Name))
|
|
{
|
|
throw new ArgumentException("Policy name is required", nameof(policy));
|
|
}
|
|
|
|
if (policy.RetentionPeriod < _options.MinRetentionPeriod)
|
|
{
|
|
throw new ArgumentException($"Retention period must be at least {_options.MinRetentionPeriod}", nameof(policy));
|
|
}
|
|
|
|
if (policy.RetentionPeriod > _options.MaxRetentionPeriod)
|
|
{
|
|
throw new ArgumentException($"Retention period cannot exceed {_options.MaxRetentionPeriod}", nameof(policy));
|
|
}
|
|
|
|
if (policy.Action == RetentionAction.Archive && string.IsNullOrEmpty(policy.ArchiveLocation))
|
|
{
|
|
throw new ArgumentException("Archive location is required for Archive action", nameof(policy));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// No-op retention handler for testing.
|
|
/// </summary>
|
|
public sealed class NoOpRetentionHandler : IRetentionHandler
|
|
{
|
|
public string DataType { get; }
|
|
|
|
public NoOpRetentionHandler(string dataType)
|
|
{
|
|
DataType = dataType;
|
|
}
|
|
|
|
public Task<long> CountAsync(RetentionQuery query, CancellationToken ct = default)
|
|
=> Task.FromResult(0L);
|
|
|
|
public Task<long> DeleteAsync(RetentionQuery query, CancellationToken ct = default)
|
|
=> Task.FromResult(0L);
|
|
|
|
public Task<long> ArchiveAsync(RetentionQuery query, string archiveLocation, CancellationToken ct = default)
|
|
=> Task.FromResult(0L);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extension methods for retention policies.
|
|
/// </summary>
|
|
public static class RetentionPolicyExtensions
|
|
{
|
|
/// <summary>
|
|
/// Creates a default retention policy for delivery logs.
|
|
/// </summary>
|
|
public static RetentionPolicy CreateDeliveryLogPolicy(
|
|
string id,
|
|
TimeSpan retention,
|
|
string? tenantId = null,
|
|
string? createdBy = null)
|
|
{
|
|
return new RetentionPolicy
|
|
{
|
|
Id = id,
|
|
Name = "Delivery Log Retention",
|
|
Description = "Automatically clean up old delivery logs",
|
|
TenantId = tenantId,
|
|
DataType = RetentionDataType.DeliveryLogs,
|
|
RetentionPeriod = retention,
|
|
Action = RetentionAction.Delete,
|
|
CreatedBy = createdBy
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a default retention policy for dead letters.
|
|
/// </summary>
|
|
public static RetentionPolicy CreateDeadLetterPolicy(
|
|
string id,
|
|
TimeSpan retention,
|
|
string? tenantId = null,
|
|
string? createdBy = null)
|
|
{
|
|
return new RetentionPolicy
|
|
{
|
|
Id = id,
|
|
Name = "Dead Letter Retention",
|
|
Description = "Automatically clean up old dead letter entries",
|
|
TenantId = tenantId,
|
|
DataType = RetentionDataType.DeadLetters,
|
|
RetentionPeriod = retention,
|
|
Action = RetentionAction.Delete,
|
|
CreatedBy = createdBy
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an archive policy for audit logs.
|
|
/// </summary>
|
|
public static RetentionPolicy CreateAuditArchivePolicy(
|
|
string id,
|
|
TimeSpan retention,
|
|
string archiveLocation,
|
|
string? tenantId = null,
|
|
string? createdBy = null)
|
|
{
|
|
return new RetentionPolicy
|
|
{
|
|
Id = id,
|
|
Name = "Audit Log Archive",
|
|
Description = "Archive old audit logs to cold storage",
|
|
TenantId = tenantId,
|
|
DataType = RetentionDataType.AuditLogs,
|
|
RetentionPeriod = retention,
|
|
Action = RetentionAction.Archive,
|
|
ArchiveLocation = archiveLocation,
|
|
CreatedBy = createdBy
|
|
};
|
|
}
|
|
}
|