feat(audit): archive retention and superseded stale-archive cleanup (ScadaBridge#109)
Owner decisions recorded: the archive destination stays a directory/UNC (no object-storage client; share + permissions named at enablement), parquet-tools-only reading (no re-import), unsigned like the chain (#107). AuditLog:Archive:RetentionDays (default 0 = never delete; else must exceed AuditLog:RetentionDays, counted from the archived month's end) and DeleteSupersededStaleArchives (default true) run after every purge tick. A retried purge's stale Archived archive is deleted only when a later, verifying, Purged archive of the same partition provably holds every row: same prior anchor and the stale chain-end hash at its chain-end sequence (row or tombstone), every unchained row present by EventId; otherwise it is reported KeptStale. A sole copy left at Archived is never touched. Deletion removes only the archive name's own three files in its folder, manifest first, never a manifest-supplied path. `audit archive cleanup [--dry-run]` = CleanupAuditArchives (Administrator). AuditLogArchiveCleanupTests (MSSQL) cover retried purge, kept stale copy, sole copy, retention and the path guard; each safety rule mutation-checked. Follow-ups filed: #113, #114, #115. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server tests for AuditLog archive cleanup (ScadaBridge#109): a stale archive a retried
|
||||
/// purge left behind is deleted only once a later purged archive provably holds all its rows;
|
||||
/// a stale archive with a row no later archive holds, or a sole copy left at <c>Archived</c>, is
|
||||
/// kept; retention deletes only expired archives whose partition is gone; deletion never
|
||||
/// follows a path out of the archive folder.
|
||||
/// </summary>
|
||||
/// <remarks>Each test owns one month of the shared fixture database and its own archive directory.</remarks>
|
||||
public sealed class AuditLogArchiveCleanupTests : IClassFixture<MsSqlMigrationFixture>, IDisposable
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("auditlog-cleanup-");
|
||||
|
||||
/// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary>
|
||||
/// <param name="fixture">The shared migration fixture.</param>
|
||||
public AuditLogArchiveCleanupTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _root.Delete(recursive: true);
|
||||
|
||||
/// <summary>
|
||||
/// The retried purge: archive A goes stale (a row arrives and the chain advances), B purges.
|
||||
/// Dry run reports A as superseded and deletes nothing; the real pass deletes exactly A's
|
||||
/// three files and B still verifies.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task RetriedPurge_StaleArchiveIsDeleted_OnceALaterPurgedArchiveCoversIt()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var dir = Dir("retried");
|
||||
var month = Month(2027, 1);
|
||||
await InsertAsync(Enumerable.Range(1, 5).Select(i => NewEvent(month.AddDays(i))));
|
||||
await DrainAsync();
|
||||
await InsertAsync([NewEvent(month.AddDays(7))]); // unchained in A
|
||||
var stale = await ArchiveAsync(dir, month);
|
||||
Assert.Equal(1, stale.UnchainedRowCount);
|
||||
|
||||
await InsertAsync([NewEvent(month.AddDays(9))]);
|
||||
await DrainAsync(); // A's unchained row is now chained; the chain moved past A's end
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
await Assert.ThrowsAsync<AuditArchiveStaleException>(() => Archive(ctx, dir).PurgeArchivedPartitionAsync(stale));
|
||||
}
|
||||
|
||||
var fresh = await ArchiveAsync(dir, month);
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
Assert.Equal(7, await Archive(ctx, dir).PurgeArchivedPartitionAsync(fresh));
|
||||
}
|
||||
|
||||
var dry = await CleanupAsync(dir, dryRun: true);
|
||||
var item = Assert.Single(dry.Items);
|
||||
Assert.Equal((stale.Name, AuditArchiveCleanupAction.DeletedSuperseded), (item.Name, item.Action));
|
||||
Assert.True(File.Exists(ManifestPath(dir, stale)));
|
||||
|
||||
var real = await CleanupAsync(dir, dryRun: false);
|
||||
Assert.Equal(AuditArchiveCleanupAction.DeletedSuperseded, Assert.Single(real.Items).Action);
|
||||
Assert.False(File.Exists(ManifestPath(dir, stale)));
|
||||
Assert.False(File.Exists(Path.Combine(dir, "2027", stale.Name + ".parquet")));
|
||||
Assert.False(File.Exists(Path.Combine(dir, "2027", stale.Name + ".tombstones.parquet")));
|
||||
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
var listed = Assert.Single(await Archive(ctx, dir).ListAsync());
|
||||
Assert.Equal(fresh.Name, listed.Name);
|
||||
Assert.True((await Archive(ctx, dir).VerifyAsync(fresh.Name)).Verified);
|
||||
}
|
||||
|
||||
Assert.Empty((await CleanupAsync(dir, dryRun: false)).Items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stale archive holding an unchained row that a per-channel purge removed before the
|
||||
/// later archive (no tombstone for an unchained row) is the only copy of that row: kept.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task StaleArchive_WithARowNoLaterArchiveHolds_IsKept()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var dir = Dir("kept");
|
||||
var month = Month(2027, 3);
|
||||
await InsertAsync(Enumerable.Range(1, 3).Select(i => NewEvent(month.AddDays(i))));
|
||||
await DrainAsync();
|
||||
await InsertAsync([NewEvent(month.AddHours(1), AuditChannel.DbOutbound)]); // unchained
|
||||
var stale = await ArchiveAsync(dir, month);
|
||||
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
Assert.Equal(1, await new AuditLogRepository(ctx).PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100));
|
||||
await Assert.ThrowsAsync<AuditArchiveStaleException>(() => Archive(ctx, dir).PurgeArchivedPartitionAsync(stale));
|
||||
}
|
||||
|
||||
var fresh = await ArchiveAsync(dir, month);
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
await Archive(ctx, dir).PurgeArchivedPartitionAsync(fresh);
|
||||
}
|
||||
|
||||
var result = await CleanupAsync(dir, dryRun: false);
|
||||
|
||||
var kept = Assert.Single(result.Items);
|
||||
Assert.Equal((stale.Name, AuditArchiveCleanupAction.KeptStale), (kept.Name, kept.Action));
|
||||
Assert.Contains("unchained row", kept.Detail);
|
||||
Assert.True(File.Exists(ManifestPath(dir, stale)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An archive left at <c>Archived</c> although its partition WAS switched out (the node
|
||||
/// stopped before the manifest update) has no later purged archive: it is the sole copy, kept.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task SoleCopyLeftAtArchived_IsNotTouched()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var dir = Dir("sole");
|
||||
var month = Month(2027, 4);
|
||||
await InsertAsync(Enumerable.Range(1, 3).Select(i => NewEvent(month.AddDays(i))));
|
||||
await DrainAsync();
|
||||
var manifest = await ArchiveAsync(dir, month);
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
await Archive(ctx, dir).PurgeArchivedPartitionAsync(manifest);
|
||||
}
|
||||
|
||||
await SetStatusAsync(dir, manifest, AuditArchiveStatus.Archived);
|
||||
|
||||
Assert.Empty((await CleanupAsync(dir, dryRun: false)).Items);
|
||||
Assert.True(File.Exists(ManifestPath(dir, manifest)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention: 0 never deletes; a purged archive is kept until its month ended more than
|
||||
/// RetentionDays ago, then deleted; an expired archive whose partition still holds rows is kept.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Retention_DeletesOnlyExpiredArchivesWhosePartitionIsGone()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var dir = Dir("retention");
|
||||
var purgedMonth = Month(2027, 5);
|
||||
await InsertAsync(Enumerable.Range(1, 2).Select(i => NewEvent(purgedMonth.AddDays(i))));
|
||||
var purged = await ArchiveAsync(dir, purgedMonth);
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
await Archive(ctx, dir).PurgeArchivedPartitionAsync(purged);
|
||||
}
|
||||
|
||||
var liveMonth = Month(2027, 6);
|
||||
await InsertAsync([NewEvent(liveMonth.AddDays(1))]);
|
||||
var live = await ArchiveAsync(dir, liveMonth); // never purged: rows still in the table
|
||||
|
||||
var monthEnd = purgedMonth.AddMonths(1);
|
||||
var farFuture = liveMonth.AddMonths(1).AddDays(1000);
|
||||
Assert.Empty((await CleanupAsync(dir, dryRun: false, retentionDays: 0, now: farFuture)).Items);
|
||||
Assert.Empty((await CleanupAsync(dir, dryRun: false, retentionDays: 400, now: monthEnd.AddDays(399))).Items);
|
||||
|
||||
var result = await CleanupAsync(dir, dryRun: false, retentionDays: 400, now: farFuture);
|
||||
|
||||
var expired = Assert.Single(result.Items);
|
||||
Assert.Equal((purged.Name, AuditArchiveCleanupAction.DeletedExpired), (expired.Name, expired.Action));
|
||||
Assert.False(File.Exists(ManifestPath(dir, purged)));
|
||||
Assert.True(File.Exists(ManifestPath(dir, live)));
|
||||
}
|
||||
|
||||
/// <summary>Deletion uses the archive name's own three files — never a path from the manifest.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[SkippableFact]
|
||||
public async Task Deletion_NeverFollowsAManifestPath_OutOfTheArchiveFolder()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
var dir = Dir("paths");
|
||||
var month = Month(2027, 7);
|
||||
await InsertAsync([NewEvent(month.AddDays(1))]);
|
||||
var manifest = await ArchiveAsync(dir, month);
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
await Archive(ctx, dir).PurgeArchivedPartitionAsync(manifest);
|
||||
}
|
||||
|
||||
var outside = Path.Combine(_root.FullName, "outside.parquet");
|
||||
await File.WriteAllTextAsync(outside, "not an archive");
|
||||
var path = ManifestPath(dir, manifest);
|
||||
var tampered = JsonSerializer.Deserialize<AuditArchiveManifest>(await File.ReadAllTextAsync(path), Json)! with
|
||||
{
|
||||
DataFile = "../../outside.parquet",
|
||||
TombstoneFile = "../../outside.parquet",
|
||||
};
|
||||
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(tampered, Json));
|
||||
|
||||
var result = await CleanupAsync(dir, dryRun: false, retentionDays: 30, now: month.AddYears(5));
|
||||
|
||||
Assert.Equal(AuditArchiveCleanupAction.DeletedExpired, Assert.Single(result.Items).Action);
|
||||
Assert.True(File.Exists(outside));
|
||||
Assert.Empty(Directory.EnumerateFiles(Path.Combine(dir, "2027")));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
private sealed class FixedClock(DateTime now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(DateTime.SpecifyKind(now, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
private string Dir(string name) => Directory.CreateDirectory(Path.Combine(_root.FullName, name)).FullName;
|
||||
|
||||
private static string ManifestPath(string dir, AuditArchiveManifest m) => Path.Combine(dir, m.Partition[..4], m.Name + ".manifest.json");
|
||||
|
||||
private static async Task SetStatusAsync(string dir, AuditArchiveManifest manifest, AuditArchiveStatus status)
|
||||
{
|
||||
var path = ManifestPath(dir, manifest);
|
||||
var current = JsonSerializer.Deserialize<AuditArchiveManifest>(await File.ReadAllTextAsync(path), Json)!;
|
||||
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(current with { Status = status, PurgedAtUtc = null }, Json));
|
||||
}
|
||||
|
||||
private static AuditLogPartitionArchive Archive(
|
||||
ScadaBridgeDbContext ctx, string dir, int retentionDays = 0, DateTime? now = null) => new(ctx, new AuditLogArchiveOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Directory = dir,
|
||||
RowGroupSize = 1_000,
|
||||
RetentionDays = retentionDays,
|
||||
}, time: now is null ? null : new FixedClock(now.Value));
|
||||
|
||||
private async Task<AuditArchiveManifest> ArchiveAsync(string dir, DateTime month)
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
var manifest = await Archive(ctx, dir).ArchivePartitionAsync(month);
|
||||
await Task.Delay(5); // distinct millisecond archive names
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private async Task<AuditArchiveCleanupResult> CleanupAsync(string dir, bool dryRun, int retentionDays = 0, DateTime? now = null)
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
return await Archive(ctx, dir, retentionDays, now).CleanupAsync(dryRun);
|
||||
}
|
||||
|
||||
private async Task InsertAsync(IEnumerable<AuditEvent> events)
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
var repo = new AuditLogRepository(ctx);
|
||||
foreach (var e in events)
|
||||
{
|
||||
await repo.InsertIfNotExistsAsync(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync()
|
||||
{
|
||||
await using var ctx = CreateContext();
|
||||
var chain = new AuditLogHashChain(ctx);
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
var r = await chain.ChainPendingAsync(500);
|
||||
if (r.RowsChained == 0 && !r.LockBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Chain did not drain.");
|
||||
}
|
||||
|
||||
private static DateTime Month(int year, int month) => new(year, month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
=> new(new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
|
||||
|
||||
private static AuditEvent NewEvent(DateTime occurredAtUtc, AuditChannel channel = AuditChannel.ApiOutbound) =>
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: channel,
|
||||
kind: channel == AuditChannel.DbOutbound ? AuditKind.DbWrite : AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
actor: "cleanup-test",
|
||||
target: "erp",
|
||||
sourceNode: "central-a",
|
||||
sourceSiteId: "cleanup-test",
|
||||
correlationId: Guid.NewGuid());
|
||||
}
|
||||
Reference in New Issue
Block a user