Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/IRetentionPolicyService.cs
master e950474a77
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
up
2025-11-27 15:16:31 +02:00

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