feat(audit): M5.5 per-channel retention overrides via purge-role bounded delete (T3)
This commit is contained in:
@@ -370,6 +370,99 @@ VALUES
|
||||
return rowsDeleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel,
|
||||
DateTime threshold,
|
||||
int batchSize,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channel))
|
||||
{
|
||||
throw new ArgumentException("Channel must be a non-empty channel name.", nameof(channel));
|
||||
}
|
||||
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
|
||||
}
|
||||
|
||||
var thresholdUtc = DateTime.SpecifyKind(threshold.ToUniversalTime(), DateTimeKind.Utc);
|
||||
|
||||
// M5.5 (T3) per-channel retention override purge. This is the ONLY DELETE
|
||||
// against dbo.AuditLog in the codebase and it runs on the purge/maintenance
|
||||
// path, NOT the append-only writer role (which has INSERT + SELECT only — see
|
||||
// the DENY UPDATE/DENY DELETE grants in CollapseAuditLogToCanonical). The
|
||||
// AuditLog append-only CI guard (AuditLogAppendOnlyGuardTests) is intentionally
|
||||
// widened to allow ONLY the single marked DELETE below; any other UPDATE/DELETE
|
||||
// targeting AuditLog still trips the guard.
|
||||
//
|
||||
// Bounded + idempotent: DELETE TOP (@batch) caps the log/lock footprint per
|
||||
// statement; the loop repeats until a batch deletes zero rows, so re-running
|
||||
// after a crash mid-loop simply resumes. Category is the canonical
|
||||
// channel-name column (e.g. 'ApiOutbound'); Action holds "{channel}.{kind}" so
|
||||
// it is NOT the right column to match a bare channel name against.
|
||||
//
|
||||
// The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the
|
||||
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
|
||||
// recognizes; any other UPDATE/DELETE targeting AuditLog still trips the guard.
|
||||
const string deleteBatchSql =
|
||||
"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"; // AUDIT-PURGE-ALLOWED: per-channel retention override (M5.5 T3), maintenance path
|
||||
|
||||
long totalDeleted = 0;
|
||||
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = deleteBatchSql;
|
||||
|
||||
var pBatch = cmd.CreateParameter();
|
||||
pBatch.ParameterName = "@batch";
|
||||
pBatch.Value = batchSize;
|
||||
cmd.Parameters.Add(pBatch);
|
||||
|
||||
var pChannel = cmd.CreateParameter();
|
||||
pChannel.ParameterName = "@channel";
|
||||
pChannel.Value = channel;
|
||||
cmd.Parameters.Add(pChannel);
|
||||
|
||||
var pThreshold = cmd.CreateParameter();
|
||||
pThreshold.ParameterName = "@threshold";
|
||||
pThreshold.Value = thresholdUtc;
|
||||
cmd.Parameters.Add(pThreshold);
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
if (rows <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
totalDeleted += rows;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold,
|
||||
|
||||
Reference in New Issue
Block a user