feat(audit): M5.5 per-channel retention overrides via purge-role bounded delete (T3)

This commit is contained in:
Joseph Doherty
2026-06-16 21:47:50 -04:00
parent 55630b48b6
commit 50b674accc
13 changed files with 583 additions and 3 deletions
@@ -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,