feat(audit): Parquet cold archive of expired AuditLog partitions before the purge (ScadaBridge#42)
Audit roadmap T2 (alog.md §15.2): with AuditLog:Archive:Enabled (default off)
the purge actor writes each expired monthly partition to Parquet under an
absolute AuditLog:Archive:Directory before switching it out, so audit rows
outlive RetentionDays.
- Parquet.Net 6.1.0 (fully managed; NuGetAudit clean) in central package
management.
- AuditArchiveParquet: data file = the ten stored columns, OccurredAtUtc as a
UTC microsecond timestamp plus exact OccurredAtUtcTicks, and ChainSeq/RowHash;
tombstone file = the month's per-channel purge tombstones. Buffered row
groups; the files are plain Parquet (DuckDB/pyarrow read them).
- AuditLogPartitionArchive (IAuditLogArchive): reads the partition's rows,
chain heads and tombstones in one transaction under the chain lock SHARED,
writes {Directory}/{YYYY}/auditlog-{YYYY-MM}-{stamp}Z.parquet,
.tombstones.parquet and a .manifest.json (counts, SHA-256 per file, each
month's head and prior anchor) via tmp + rename, manifest last. Archives are
never overwritten.
- Archive before purge: SwitchOutPartitionAsync gains an internal guarded
overload. Under the EXCLUSIVE chain lock it re-counts the partition and
re-reads each month's LastSeq and THROWs 51002/51003 (nothing purged) unless
both still equal the archive's, so the switch's checkpoint anchor is exactly
the archive's chain end. The manifest is then marked Purged.
- Fail closed: a partition that cannot be archived is not purged; the failure
takes the existing AuditLogPurgeFailedEvent / PurgeFailures path. Success
publishes AuditLogArchivedEvent.
- Verify: file hashes and counts against the manifest, the chain recomputed
from the archived rows and tombstones (AuditChainVerifier), and the chain end
against the database anchor (Matches / Superseded / NotPurged / Mismatch).
- AuditLogArchiveOptionsValidator: Directory required when enabled and must be
absolute; RowGroupSize, timeouts bounded.
No migration: the purger role already reads AuditLog, the head and tombstones.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -50,7 +50,8 @@ public sealed class AuditLogHashChain : IAuditLogHashChain
|
||||
// 4 parameters per row; 200 rows = 800 parameters, far under SQL Server's 2,100 cap.
|
||||
private const int UpdateChunkRows = 200;
|
||||
|
||||
private const string RowColumns =
|
||||
/// <summary>The ten writable AuditLog columns, in table order — the hashed row content.</summary>
|
||||
internal const string RowColumns =
|
||||
"EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson";
|
||||
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
@@ -344,7 +345,11 @@ public sealed class AuditLogHashChain : IAuditLogHashChain
|
||||
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false) == 1;
|
||||
}
|
||||
|
||||
private static AuditChainRow ReadRow(DbDataReader reader, bool withLink)
|
||||
/// <summary>
|
||||
/// Reads one row selected as <see cref="RowColumns"/> (plus <c>ChainSeq, RowHash</c> when
|
||||
/// <paramref name="withLink"/>), strictly in ordinal order so it is safe under SequentialAccess.
|
||||
/// </summary>
|
||||
internal static AuditChainRow ReadRow(DbDataReader reader, bool withLink)
|
||||
{
|
||||
// Ordinals follow RowColumns (+ ChainSeq, RowHash). Read strictly in order — the
|
||||
// verification reader is opened with SequentialAccess.
|
||||
@@ -390,7 +395,7 @@ public sealed class AuditLogHashChain : IAuditLogHashChain
|
||||
cmd.Parameters.Add(new SqlParameter("@monthEnd", SqlDbType.DateTime2) { Scale = 7, Value = month.AddMonths(1) });
|
||||
}
|
||||
|
||||
private static async Task<bool> OpenAsync(DbConnection conn, CancellationToken ct)
|
||||
internal static async Task<bool> OpenAsync(DbConnection conn, CancellationToken ct)
|
||||
{
|
||||
if (conn.State == ConnectionState.Open)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user