refinement phase 9
This commit is contained in:
+33
@@ -77,4 +77,37 @@ internal sealed class AuditLogger(
|
||||
|
||||
return new PagedResult<AuditLogDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async ValueTask<int> PurgeExpiredAsync(
|
||||
int generalRetentionDays,
|
||||
int financialRetentionDays,
|
||||
IReadOnlyCollection<string> financialEntityTypes,
|
||||
int maxRowsPerRun,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
var generalCutoff = now.AddDays(-generalRetentionDays);
|
||||
var financialCutoff = now.AddDays(-financialRetentionDays);
|
||||
|
||||
// Oldest-first (Id is monotonic with OccurredAt), capped. The age comparison is done in memory — the
|
||||
// SQLite test provider can't translate a DateTimeOffset predicate — and the delete is a single id-keyed
|
||||
// statement that translates on every provider. A financial/compliance row survives until the longer window.
|
||||
var candidates = await db.Set<AuditLog>().AsNoTracking()
|
||||
.OrderBy(a => a.Id)
|
||||
.Select(a => new { a.Id, a.EntityType, a.OccurredAt })
|
||||
.Take(maxRowsPerRun)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var expiredIds = candidates
|
||||
.Where(a => a.OccurredAt < (financialEntityTypes.Contains(a.EntityType) ? financialCutoff : generalCutoff))
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
if (expiredIds.Count == 0)
|
||||
return 0;
|
||||
|
||||
return await db.Set<AuditLog>()
|
||||
.Where(a => expiredIds.Contains(a.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Retention/archival policy for the append-only <c>ops.AuditLogs</c> table (refinement-phase-9 §9.4). The audit
|
||||
/// trail is never pruned by the application except here, on a schedule, and even then a two-tier policy protects
|
||||
/// the legally-sensitive rows: <b>financial & verification</b> entity types (refunds, clawbacks, payouts,
|
||||
/// payout batches, verification decisions, config changes, partner centers) keep a long window
|
||||
/// (<c>audit_retention_financial_days</c>, default ~7 years); everyday operational rows keep a shorter one
|
||||
/// (<c>audit_retention_general_days</c>, default ~2 years). Idempotent: a re-run simply finds nothing new to
|
||||
/// delete. Runs on the <c>audit_retention_scan_cadence_hours</c> cadence.
|
||||
/// </summary>
|
||||
internal sealed class AuditLogRetentionJob(ILogger<AuditLogRetentionJob> logger) : IRecurringJob
|
||||
{
|
||||
// Bound the per-run working set so a large backlog drains across successive runs without loading the whole table.
|
||||
private const int MaxRowsPerRun = 20_000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>IAuditable</c> entity types whose audit rows carry money- or verification-legal weight. Their
|
||||
/// <c>EntityType</c> is the CLR type name written by <c>AuditFieldInterceptor</c>. Everything else uses the
|
||||
/// shorter general window.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyCollection<string> FinancialEntityTypes =
|
||||
[
|
||||
"Refund",
|
||||
"NurseClawback",
|
||||
"NursePayout",
|
||||
"NursePayoutBatch",
|
||||
"NurseVerification",
|
||||
"PlatformConfig",
|
||||
"PartnerCenter"
|
||||
];
|
||||
|
||||
public string Name => "audit_log_retention";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var hours = await config.GetConfig<int>("audit_retention_scan_cadence_hours", cancellationToken);
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var audit = services.GetRequiredService<IAuditLogger>();
|
||||
|
||||
var generalDays = await config.GetConfig<int>("audit_retention_general_days", cancellationToken);
|
||||
var financialDays = await config.GetConfig<int>("audit_retention_financial_days", cancellationToken);
|
||||
|
||||
var deleted = await audit.PurgeExpiredAsync(
|
||||
generalDays, financialDays, FinancialEntityTypes, MaxRowsPerRun, cancellationToken);
|
||||
|
||||
if (deleted > 0)
|
||||
logger.LogInformation(
|
||||
"Audit retention purged {Count} audit row(s) (general>{GeneralDays}d, financial>{FinancialDays}d)",
|
||||
deleted, generalDays, financialDays);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user