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:
Joseph Doherty
2026-11-09 17:00:49 -05:00
parent 31cb2c79e3
commit 7be4f19861
18 changed files with 2232 additions and 8 deletions
+3
View File
@@ -78,6 +78,9 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
<!-- Parquet audit archival + the parquet format of `audit export` (ScadaBridge#42). 6.1.0 is fully
managed (Snappier / ZstdSharp / K4os LZ4, no native IronCompress) and NuGetAudit-clean. -->
<PackageVersion Include="Parquet.Net" Version="6.1.0" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
@@ -0,0 +1,36 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="AuditLogArchiveOptions"/> (section <c>AuditLog:Archive</c>; Parquet
/// cold archive, ScadaBridge#42) at host startup. An enabled archive must name an absolute
/// directory: a relative one would resolve against the service's working directory, which
/// differs between the two central nodes and moves on every redeploy.
/// </summary>
public sealed class AuditLogArchiveOptionsValidator : OptionsValidatorBase<AuditLogArchiveOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AuditLogArchiveOptions options)
{
if (options.Enabled)
{
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Directory),
"AuditLog:Archive:Directory is required when AuditLog:Archive:Enabled is true.");
}
if (!string.IsNullOrWhiteSpace(options.Directory))
{
builder.RequireThat(Path.IsPathFullyQualified(options.Directory),
$"AuditLog:Archive:Directory ('{options.Directory}') must be an absolute path reachable by both central nodes.");
}
builder.RequireThat(options.RowGroupSize is >= 1_000 and <= 1_000_000,
$"AuditLog:Archive:{nameof(AuditLogArchiveOptions.RowGroupSize)} ({options.RowGroupSize}) must be between 1000 and 1000000.");
builder.RequireThat(options.ReadCommandTimeoutMinutes > 0,
$"AuditLog:Archive:{nameof(AuditLogArchiveOptions.ReadCommandTimeoutMinutes)} ({options.ReadCommandTimeoutMinutes}) must be > 0.");
builder.RequireThat(options.ChainLockTimeoutSeconds > 0,
$"AuditLog:Archive:{nameof(AuditLogArchiveOptions.ChainLockTimeoutSeconds)} ({options.ChainLockTimeoutSeconds}) must be > 0.");
}
}
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Published on the actor-system EventStream by <see cref="AuditLogPurgeActor"/> when an
/// expired partition was written to a Parquet archive and then switched out
/// (ScadaBridge#42). Followed by the usual <see cref="AuditLogPurgedEvent"/>.
/// </summary>
/// <param name="MonthBoundary">The partition's lower boundary (UTC).</param>
/// <param name="ArchiveName">The archive name (<c>audit archive verify --name</c> takes it).</param>
/// <param name="RowCount">Rows written to the archive.</param>
/// <param name="DataBytes">Size of the archive's data file in bytes.</param>
public sealed record AuditLogArchivedEvent(
DateTime MonthBoundary,
string ArchiveName,
long RowCount,
long DataBytes);
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
@@ -20,6 +21,9 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/>, a
/// metadata-only staging-table switch (no index
/// drop/rebuild brackets it).</item>
/// <item>When <c>AuditLog:Archive:Enabled</c> (ScadaBridge#42, default off), each boundary
/// is first written to a Parquet archive via <see cref="IAuditLogArchive"/> and then
/// switched out by the archive's guarded purge instead of the plain switch.</item>
/// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system
/// EventStream so the central health collector + ops surfaces
/// can subscribe without coupling to this actor.</item>
@@ -188,6 +192,27 @@ public class AuditLogPurgeActor : ReceiveActor
return;
}
// Parquet cold archive (ScadaBridge#42, default off). When enabled, every expired
// partition is archived first and switched out only by the archive's guarded purge,
// so a partition that cannot be archived is never dropped (fail closed: the table
// grows and the failure surfaces on the health counter instead of rows being lost).
var archiveOptions = scope.ServiceProvider.GetService<IOptions<AuditLogArchiveOptions>>()?.Value;
IAuditLogArchive? archive = null;
if (archiveOptions?.Enabled == true)
{
try
{
archive = scope.ServiceProvider.GetRequiredService<IAuditLogArchive>();
}
catch (Exception ex)
{
_logger.LogError(ex, "AuditLog archival is enabled but IAuditLogArchive could not be resolved; skipping partition purge.");
PublishPurgeFailure(eventStream, purgeFailureCounter, DateTime.MinValue, ex, 0);
await RunPerChannelOverridesAsync(repository, eventStream, purgeFailureCounter).ConfigureAwait(false);
return;
}
}
foreach (var boundary in boundaries)
{
// Per-boundary try/catch: one bad partition (transient SQL
@@ -196,9 +221,22 @@ public class AuditLogPurgeActor : ReceiveActor
var sw = Stopwatch.StartNew();
try
{
var rowsDeleted = await repository
.SwitchOutPartitionAsync(boundary, _purgeOptions.ResolvedMaintenanceCommandTimeout)
.ConfigureAwait(false);
long rowsDeleted;
if (archive is null)
{
rowsDeleted = await repository
.SwitchOutPartitionAsync(boundary, _purgeOptions.ResolvedMaintenanceCommandTimeout)
.ConfigureAwait(false);
}
else
{
var manifest = await archive.ArchivePartitionAsync(boundary).ConfigureAwait(false);
rowsDeleted = await archive
.PurgeArchivedPartitionAsync(manifest, _purgeOptions.ResolvedMaintenanceCommandTimeout)
.ConfigureAwait(false);
eventStream.Publish(new AuditLogArchivedEvent(boundary, manifest.Name, manifest.RowCount, manifest.DataBytes));
}
sw.Stop();
eventStream.Publish(
@@ -55,6 +55,12 @@ public static class ServiceCollectionExtensions
/// <summary>Configuration section bound to <see cref="AuditLogHashChainOptions"/> (ScadaBridge#41).</summary>
public const string HashChainSectionName = "AuditLog:HashChain";
/// <summary>
/// Configuration section bound to
/// <see cref="ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance.AuditLogArchiveOptions"/> (ScadaBridge#42).
/// </summary>
public const string ArchiveSectionName = "AuditLog:Archive";
/// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions"/>.</summary>
public const string PurgeSectionName = "AuditLog:Purge";
@@ -405,6 +411,12 @@ public static class ServiceCollectionExtensions
config, HashChainSectionName);
services.AddHostedService<AuditLogHashChainService>();
// Parquet cold archive (ScadaBridge#42). Default OFF; when enabled the purge actor
// archives each expired partition before switching it out. IAuditLogArchive is
// registered by AddConfigurationDatabase (central only) and reads these options.
services.AddValidatedOptions<ConfigurationDatabase.Maintenance.AuditLogArchiveOptions, AuditLogArchiveOptionsValidator>(
config, ArchiveSectionName);
// I1 (review): bind the two central-singleton options HERE rather than in
// AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
// resolves IOptions<AuditLogPurgeOptions> / <SiteAuditReconciliationOptions>
@@ -0,0 +1,82 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Parquet cold archive of the central <c>AuditLog</c> (audit roadmap T2, alog.md §15.2,
/// ScadaBridge#42): a monthly partition is written to Parquet before the retention purge
/// switches it out, so audit rows outlive the SQL retention window.
/// </summary>
/// <remarks>
/// <para>
/// <b>Archive before purge.</b> <see cref="ArchivePartitionAsync"/> writes the partition's
/// rows (with their hash-chain columns), the month's chain head and its per-channel purge
/// tombstones; <see cref="PurgeArchivedPartitionAsync"/> then switches the partition out
/// inside a guard that refuses to drop it if the partition gained or lost a row, or its
/// chain advanced, since the archive was written. A partition is therefore never purged
/// without an archive that holds exactly the rows the purge drops.
/// </para>
/// <para>
/// <b>Hash chain.</b> The switch records each month's chain end as the checkpoint anchor
/// (ScadaBridge#41); the archive records the same chain end, so <see cref="VerifyAsync"/>
/// can recompute the archived chain and tie its end to the anchor still in the database.
/// </para>
/// </remarks>
public interface IAuditLogArchive
{
/// <summary>True when an archive directory is configured (listing and verifying need one; archiving needs <c>Enabled</c> too).</summary>
bool IsConfigured { get; }
/// <summary>True when the retention purge archives partitions before dropping them (<c>AuditLog:Archive:Enabled</c>).</summary>
bool IsEnabled { get; }
/// <summary>
/// Writes the partition whose lower boundary is <paramref name="partitionBoundaryUtc"/> to
/// a new archive (data file, tombstone file, then manifest with status
/// <see cref="AuditArchiveStatus.Archived"/>).
/// </summary>
/// <param name="partitionBoundaryUtc">The <c>pf_AuditLog_Month</c> lower boundary (day 1, midnight UTC).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The written manifest.</returns>
Task<AuditArchiveManifest> ArchivePartitionAsync(DateTime partitionBoundaryUtc, CancellationToken ct = default);
/// <summary>
/// Switches the archived partition out of <c>dbo.AuditLog</c>, guarded against the
/// partition having changed since <paramref name="manifest"/> was written, then marks the
/// manifest <see cref="AuditArchiveStatus.Purged"/>.
/// </summary>
/// <param name="manifest">The manifest <see cref="ArchivePartitionAsync"/> returned.</param>
/// <param name="commandTimeout">Maintenance command timeout for the switch.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The approximate number of rows the switch discarded.</returns>
/// <exception cref="AuditArchiveStaleException">The partition changed after it was archived; nothing was purged.</exception>
Task<long> PurgeArchivedPartitionAsync(AuditArchiveManifest manifest, TimeSpan? commandTimeout = null, CancellationToken ct = default);
/// <summary>Lists every archive manifest under the archive directory, oldest partition first.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>The manifests.</returns>
Task<IReadOnlyList<AuditArchiveManifest>> ListAsync(CancellationToken ct = default);
/// <summary>
/// Verifies one archive: file integrity against the manifest, the hash chain recomputed
/// over the archived rows, and the chain end against the database's checkpoint anchor.
/// </summary>
/// <param name="name">The archive name (as <see cref="ListAsync"/> reports it).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The verification result.</returns>
/// <exception cref="FileNotFoundException">No archive has that name.</exception>
Task<AuditArchiveVerificationResult> VerifyAsync(string name, CancellationToken ct = default);
}
/// <summary>
/// Thrown by <see cref="IAuditLogArchive.PurgeArchivedPartitionAsync"/> when the partition
/// gained or lost rows, or its hash chain advanced, after the archive was written. Nothing
/// is purged; the next purge tick writes a fresh archive.
/// </summary>
public sealed class AuditArchiveStaleException : Exception
{
/// <summary>Initializes a new instance with a message and the SQL error that carried it.</summary>
/// <param name="message">The message.</param>
/// <param name="inner">The underlying exception.</param>
public AuditArchiveStaleException(string message, Exception? inner = null) : base(message, inner) { }
}
@@ -0,0 +1,147 @@
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Lifecycle of one AuditLog Parquet archive (audit roadmap T2, ScadaBridge#42).
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AuditArchiveStatus>))]
public enum AuditArchiveStatus
{
/// <summary>
/// The archive files are durable but the partition switch that should follow has not
/// been confirmed: either it failed (the archive went stale and a later tick wrote a
/// fresh one) or the node stopped between the switch and the manifest update. Verify
/// tells the two apart: only the second one's chain end matches the database anchor.
/// </summary>
Archived,
/// <summary>The archived partition was switched out of <c>dbo.AuditLog</c>; the archive is the only copy.</summary>
Purged,
}
/// <summary>
/// One chain partition (UTC calendar month) as it stood when its rows were archived: the
/// chain head and the checkpoint anchor an earlier partition purge had left, so the archived
/// rows can be re-verified with <c>AuditChainVerifier</c> long after the database dropped them.
/// </summary>
/// <param name="Partition">The chain partition, <c>YYYY-MM</c>.</param>
/// <param name="LastSeq">The head's last sequence number at archive time.</param>
/// <param name="LastHash">The head's last hash at archive time (lowercase hex).</param>
/// <param name="PriorPurgedThroughSeq">The checkpoint anchor an earlier purge of this month left, or null.</param>
/// <param name="PriorPurgedThroughHash">The hash at <paramref name="PriorPurgedThroughSeq"/> (lowercase hex), or null.</param>
/// <param name="TombstoneCount">Per-channel purge tombstones of this month carried in the archive.</param>
public sealed record AuditArchiveChainState(
string Partition,
long LastSeq,
string LastHash,
long? PriorPurgedThroughSeq,
string? PriorPurgedThroughHash,
long TombstoneCount);
/// <summary>
/// The JSON manifest written beside every AuditLog Parquet archive (ScadaBridge#42). It is
/// written LAST, so its presence is the commit marker for the archive's two Parquet files.
/// </summary>
/// <param name="FormatVersion">Manifest/file layout version (currently 1).</param>
/// <param name="Name">Archive name, <c>auditlog-YYYY-MM-yyyyMMddTHHmmssfffZ</c>; the file stem of all three files.</param>
/// <param name="PartitionBoundaryUtc">The <c>pf_AuditLog_Month</c> lower boundary of the archived partition.</param>
/// <param name="Partition">The archived partition, <c>YYYY-MM</c>.</param>
/// <param name="ArchivedAtUtc">When the archive was written.</param>
/// <param name="Status">Whether the partition switch that follows the archive has been confirmed.</param>
/// <param name="PurgedAtUtc">When the partition switch completed, or null.</param>
/// <param name="RowCount">Rows in the data file.</param>
/// <param name="ChainedRowCount">Rows that carried a ChainSeq/RowHash.</param>
/// <param name="UnchainedRowCount">Rows the chaining job had not reached (not covered by the chain).</param>
/// <param name="MinOccurredAtUtc">Earliest OccurredAtUtc in the data file, or null when empty.</param>
/// <param name="MaxOccurredAtUtc">Latest OccurredAtUtc in the data file, or null when empty.</param>
/// <param name="DataFile">Data file name, relative to the manifest's directory.</param>
/// <param name="DataSha256">SHA-256 of the data file (lowercase hex).</param>
/// <param name="DataBytes">Size of the data file in bytes.</param>
/// <param name="TombstoneFile">Tombstone file name, relative to the manifest's directory.</param>
/// <param name="TombstoneSha256">SHA-256 of the tombstone file (lowercase hex).</param>
/// <param name="TombstoneCount">Tombstones in the tombstone file.</param>
/// <param name="Chains">One entry per chain partition that had a head when the rows were archived.</param>
public sealed record AuditArchiveManifest(
int FormatVersion,
string Name,
DateTime PartitionBoundaryUtc,
string Partition,
DateTime ArchivedAtUtc,
AuditArchiveStatus Status,
DateTime? PurgedAtUtc,
long RowCount,
long ChainedRowCount,
long UnchainedRowCount,
DateTime? MinOccurredAtUtc,
DateTime? MaxOccurredAtUtc,
string DataFile,
string DataSha256,
long DataBytes,
string TombstoneFile,
string TombstoneSha256,
long TombstoneCount,
IReadOnlyList<AuditArchiveChainState> Chains);
/// <summary>
/// How an archive's recorded chain end relates to the checkpoint anchor the partition purge
/// left on the database's chain head.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AuditArchiveAnchorStatus>))]
public enum AuditArchiveAnchorStatus
{
/// <summary>The database anchor is exactly the archive's chain end (sequence and hash).</summary>
Matches,
/// <summary>
/// The database anchor is past the archive's chain end: rows that arrived late for this
/// month were chained and purged (and archived) again afterwards.
/// </summary>
Superseded,
/// <summary>The partition has not been purged: the archive's rows are still in <c>dbo.AuditLog</c>.</summary>
NotPurged,
/// <summary>
/// The anchor disagrees with the archive (an earlier sequence number, a different hash, or
/// no chain head at all): the archive or the head was altered.
/// </summary>
Mismatch,
}
/// <summary>Verification of one chain partition inside an archive.</summary>
/// <param name="Chain">The chain recomputed over the archived rows, the archived head and tombstones.</param>
/// <param name="Anchor">How the archived chain end relates to the database's checkpoint anchor.</param>
/// <param name="AnchorDetail">Human-readable explanation of <paramref name="Anchor"/>.</param>
public sealed record AuditArchiveChainVerification(
AuditChainVerificationResult Chain,
AuditArchiveAnchorStatus Anchor,
string AnchorDetail);
/// <summary>
/// Result of <c>audit archive verify</c> (ScadaBridge#42): the archive files match their
/// manifest, every archived chain recomputes to the archived head, and each chain end is the
/// anchor the partition purge left in the database.
/// </summary>
/// <param name="Name">The archive verified.</param>
/// <param name="Partition">The archived partition, <c>YYYY-MM</c>.</param>
/// <param name="ManifestStatus">The manifest's lifecycle status.</param>
/// <param name="Verified">True when the files are intact, no chain is broken and no anchor mismatches.</param>
/// <param name="FilesIntact">True when both files' size and SHA-256 and the row/tombstone counts match the manifest.</param>
/// <param name="FileProblem">Why <paramref name="FilesIntact"/> is false, or null.</param>
/// <param name="RowsRead">Rows read from the data file.</param>
/// <param name="Chains">Per chain partition verification.</param>
public sealed record AuditArchiveVerificationResult(
string Name,
string Partition,
AuditArchiveStatus ManifestStatus,
bool Verified,
bool FilesIntact,
string? FileProblem,
long RowsRead,
IReadOnlyList<AuditArchiveChainVerification> Chains);
/// <summary>Result of <c>audit archive list</c> (ScadaBridge#42).</summary>
/// <param name="Enabled">Whether the retention purge is archiving partitions (<c>AuditLog:Archive:Enabled</c>).</param>
/// <param name="Archives">Every archive manifest, oldest partition first.</param>
public sealed record AuditArchiveListResult(bool Enabled, IReadOnlyList<AuditArchiveManifest> Archives);
@@ -0,0 +1,428 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using Parquet;
using Parquet.Schema;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// One per-channel purge tombstone carried in an archive (ScadaBridge#41/#42): the link a
/// sanctioned per-channel retention DELETE removed from the middle of a month's chain.
/// </summary>
/// <param name="ChainMonth">The chain partition's first instant (UTC).</param>
/// <param name="ChainSeq">The purged row's sequence number.</param>
/// <param name="RowHash">The purged row's 32-byte hash.</param>
/// <param name="EventId">The purged row's EventId.</param>
/// <param name="PurgedAtUtc">When the per-channel purge removed it.</param>
public sealed record AuditArchiveTombstone(
DateTime ChainMonth,
long ChainSeq,
byte[] RowHash,
Guid EventId,
DateTime PurgedAtUtc);
/// <summary>
/// The Parquet layout of an AuditLog archive (audit roadmap T2, ScadaBridge#42), format v1.
/// </summary>
/// <remarks>
/// <para>
/// <b>Data file</b> — one row per archived <c>dbo.AuditLog</c> row, the ten stored (writable)
/// columns plus the two hash-chain columns, so a row can be re-hashed with
/// <see cref="AuditChainHasher"/> exactly as the database chained it:
/// <c>EventId</c> (string, <c>D</c> format), <c>OccurredAtUtc</c> (timestamp, UTC, microseconds —
/// for analytics), <c>OccurredAtUtcTicks</c> (int64 — the exact <c>datetime2(7)</c> value the hash
/// covers), <c>Actor</c>, <c>Action</c>, <c>Outcome</c>, <c>Category</c>, <c>Target</c>,
/// <c>SourceNode</c>, <c>CorrelationId</c>, <c>DetailsJson</c> (strings), <c>ChainSeq</c> (int64,
/// null when unchained) and <c>RowHash</c> (binary, null when unchained). The persisted computed
/// columns (Kind, Status, SourceSiteId, …) are not stored: they are derived from
/// <c>DetailsJson</c>, which the hash covers.
/// </para>
/// <para>
/// <b>Tombstone file</b> — <c>ChainMonth</c> (<c>YYYY-MM</c>), <c>ChainSeq</c>, <c>RowHash</c>,
/// <c>EventId</c>, <c>PurgedAtUtc</c>.
/// </para>
/// <para>
/// Both are plain Parquet: DuckDB (<c>SELECT * FROM 'auditlog-….parquet'</c>), pandas/pyarrow
/// and Spark read them without any ScadaBridge code.
/// </para>
/// </remarks>
public static class AuditArchiveParquet
{
/// <summary>The archive layout version written into the file metadata and the manifest.</summary>
public const int FormatVersion = 1;
/// <summary>Key-value metadata key carrying <see cref="FormatVersion"/>.</summary>
public const string FormatVersionKey = "scadabridge.auditlog.archive.formatVersion";
/// <summary>Key-value metadata key carrying the archived partition (<c>YYYY-MM</c>).</summary>
public const string PartitionKey = "scadabridge.auditlog.archive.partition";
private static readonly DataField<string> EventIdField = new("EventId", false);
private static readonly DateTimeDataField OccurredAtField =
new("OccurredAtUtc", DateTimeFormat.Timestamp, isAdjustedToUTC: true, unit: DateTimeTimeUnit.Micros, isNullable: false);
private static readonly DataField<long> OccurredAtTicksField = new("OccurredAtUtcTicks", false);
private static readonly DataField<string> ActorField = new("Actor", true);
private static readonly DataField<string> ActionField = new("Action", false);
private static readonly DataField<string> OutcomeField = new("Outcome", false);
private static readonly DataField<string> CategoryField = new("Category", false);
private static readonly DataField<string> TargetField = new("Target", true);
private static readonly DataField<string> SourceNodeField = new("SourceNode", true);
private static readonly DataField<string> CorrelationIdField = new("CorrelationId", true);
private static readonly DataField<string> DetailsJsonField = new("DetailsJson", true);
private static readonly DataField<long> ChainSeqField = new("ChainSeq", true);
private static readonly DataField<byte[]> RowHashField = new("RowHash", true);
private static readonly DataField<string> TombstoneMonthField = new("ChainMonth", false);
private static readonly DataField<long> TombstoneSeqField = new("ChainSeq", false);
private static readonly DataField<byte[]> TombstoneHashField = new("RowHash", false);
private static readonly DataField<string> TombstoneEventIdField = new("EventId", false);
private static readonly DateTimeDataField TombstonePurgedAtField =
new("PurgedAtUtc", DateTimeFormat.Timestamp, isAdjustedToUTC: true, unit: DateTimeTimeUnit.Micros, isNullable: false);
/// <summary>The data-file schema.</summary>
public static ParquetSchema DataSchema { get; } = new(
EventIdField, OccurredAtField, OccurredAtTicksField, ActorField, ActionField, OutcomeField, CategoryField,
TargetField, SourceNodeField, CorrelationIdField, DetailsJsonField, ChainSeqField, RowHashField);
/// <summary>The tombstone-file schema.</summary>
public static ParquetSchema TombstoneSchema { get; } = new(
TombstoneMonthField, TombstoneSeqField, TombstoneHashField, TombstoneEventIdField, TombstonePurgedAtField);
/// <summary>Opens a buffered writer of archive data rows over <paramref name="output"/>.</summary>
/// <param name="output">Destination stream (need not be seekable).</param>
/// <param name="partition">The archived partition (<c>YYYY-MM</c>), stamped into the file metadata.</param>
/// <param name="rowGroupSize">Rows per Parquet row group.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The writer; dispose it to flush the last row group and the footer.</returns>
public static async Task<DataWriter> CreateDataWriterAsync(Stream output, string partition, int rowGroupSize, CancellationToken ct = default)
{
var writer = await ParquetWriter.CreateAsync(DataSchema, output, cancellationToken: ct).ConfigureAwait(false);
writer.CustomMetadata = Metadata(partition);
return new DataWriter(writer, rowGroupSize);
}
/// <summary>Opens a buffered writer of tombstones over <paramref name="output"/>.</summary>
/// <param name="output">Destination stream (need not be seekable).</param>
/// <param name="partition">The archived partition (<c>YYYY-MM</c>), stamped into the file metadata.</param>
/// <param name="rowGroupSize">Rows per Parquet row group.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The writer; dispose it to flush the last row group and the footer.</returns>
public static async Task<TombstoneWriter> CreateTombstoneWriterAsync(Stream output, string partition, int rowGroupSize, CancellationToken ct = default)
{
var writer = await ParquetWriter.CreateAsync(TombstoneSchema, output, cancellationToken: ct).ConfigureAwait(false);
writer.CustomMetadata = Metadata(partition);
return new TombstoneWriter(writer, rowGroupSize);
}
/// <summary>Streams the rows of an archive data file in file order.</summary>
/// <param name="input">A readable, seekable stream over the data file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The rows, as the chain hasher sees them.</returns>
public static async IAsyncEnumerable<AuditChainRow> ReadRowsAsync(Stream input, [EnumeratorCancellation] CancellationToken ct = default)
{
await using var reader = await ParquetReader.CreateAsync(input, leaveStreamOpen: true, cancellationToken: ct).ConfigureAwait(false);
for (var g = 0; g < reader.RowGroupCount; g++)
{
using var group = reader.OpenRowGroupReader(g);
var n = checked((int)group.RowCount);
var eventIds = await Strings(group, EventIdField, n, ct).ConfigureAwait(false);
var ticks = new long[n];
await group.ReadAsync<long>(OccurredAtTicksField, ticks, cancellationToken: ct).ConfigureAwait(false);
var actors = await Strings(group, ActorField, n, ct).ConfigureAwait(false);
var actions = await Strings(group, ActionField, n, ct).ConfigureAwait(false);
var outcomes = await Strings(group, OutcomeField, n, ct).ConfigureAwait(false);
var categories = await Strings(group, CategoryField, n, ct).ConfigureAwait(false);
var targets = await Strings(group, TargetField, n, ct).ConfigureAwait(false);
var nodes = await Strings(group, SourceNodeField, n, ct).ConfigureAwait(false);
var correlations = await Strings(group, CorrelationIdField, n, ct).ConfigureAwait(false);
var details = await Strings(group, DetailsJsonField, n, ct).ConfigureAwait(false);
var seqs = new long?[n];
await group.ReadAsync<long>(ChainSeqField, seqs, cancellationToken: ct).ConfigureAwait(false);
var hashes = new byte[]?[n];
await group.ReadAsync(RowHashField, hashes!, cancellationToken: ct).ConfigureAwait(false);
for (var i = 0; i < n; i++)
{
yield return new AuditChainRow(
Guid.Parse(eventIds[i]!),
new DateTime(ticks[i], DateTimeKind.Utc),
actors[i],
actions[i]!,
outcomes[i]!,
categories[i]!,
targets[i],
nodes[i],
correlations[i] is { } c ? Guid.Parse(c) : null,
details[i],
seqs[i],
hashes[i]);
}
}
}
/// <summary>Streams the tombstones of an archive tombstone file.</summary>
/// <param name="input">A readable, seekable stream over the tombstone file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The tombstones.</returns>
public static async IAsyncEnumerable<AuditArchiveTombstone> ReadTombstonesAsync(Stream input, [EnumeratorCancellation] CancellationToken ct = default)
{
await using var reader = await ParquetReader.CreateAsync(input, leaveStreamOpen: true, cancellationToken: ct).ConfigureAwait(false);
for (var g = 0; g < reader.RowGroupCount; g++)
{
using var group = reader.OpenRowGroupReader(g);
var n = checked((int)group.RowCount);
var months = await Strings(group, TombstoneMonthField, n, ct).ConfigureAwait(false);
var seqs = new long[n];
await group.ReadAsync<long>(TombstoneSeqField, seqs, cancellationToken: ct).ConfigureAwait(false);
var hashes = new byte[]?[n];
await group.ReadAsync(TombstoneHashField, hashes!, cancellationToken: ct).ConfigureAwait(false);
var eventIds = await Strings(group, TombstoneEventIdField, n, ct).ConfigureAwait(false);
var purgedAt = new DateTime[n];
await group.ReadAsync<DateTime>(TombstonePurgedAtField, purgedAt, cancellationToken: ct).ConfigureAwait(false);
for (var i = 0; i < n; i++)
{
yield return new AuditArchiveTombstone(
ParseMonth(months[i]!),
seqs[i],
hashes[i]!,
Guid.Parse(eventIds[i]!),
DateTime.SpecifyKind(purgedAt[i], DateTimeKind.Utc));
}
}
}
/// <summary>Parses a <c>YYYY-MM</c> chain partition label to its first instant (UTC).</summary>
/// <param name="partition">The label.</param>
/// <returns>Day 1, midnight UTC.</returns>
public static DateTime ParseMonth(string partition)
{
var month = DateTime.ParseExact(partition, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None);
return new DateTime(month.Year, month.Month, 1, 0, 0, 0, DateTimeKind.Utc);
}
private static Dictionary<string, string> Metadata(string partition) => new()
{
[FormatVersionKey] = FormatVersion.ToString(CultureInfo.InvariantCulture),
[PartitionKey] = partition,
};
private static async Task<string?[]> Strings(ParquetRowGroupReader group, DataField<string> field, int n, CancellationToken ct)
{
var values = new string?[n];
await group.ReadAsync(field, values!, cancellationToken: ct).ConfigureAwait(false);
return values;
}
/// <summary>Buffers archive data rows and writes them a row group at a time.</summary>
public sealed class DataWriter : IAsyncDisposable
{
private readonly ParquetWriter _writer;
private readonly int _rowGroupSize;
private readonly List<AuditChainRow> _buffer;
private bool _disposed;
internal DataWriter(ParquetWriter writer, int rowGroupSize)
{
_writer = writer;
_rowGroupSize = Math.Max(1, rowGroupSize);
_buffer = new List<AuditChainRow>(Math.Min(_rowGroupSize, 65_536));
}
/// <summary>Rows added so far.</summary>
public long RowCount { get; private set; }
/// <summary>Adds one row, flushing a row group when the buffer is full.</summary>
/// <param name="row">The row.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when the row is buffered (or its row group written).</returns>
public async Task AddAsync(AuditChainRow row, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(row);
_buffer.Add(row);
RowCount++;
if (_buffer.Count >= _rowGroupSize)
{
await FlushAsync(ct).ConfigureAwait(false);
}
}
/// <summary>Writes any buffered rows as a row group, then the file footer.</summary>
/// <returns>A task that completes when the file is finished.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await FlushAsync(CancellationToken.None).ConfigureAwait(false);
await _writer.DisposeAsync().ConfigureAwait(false);
}
private async Task FlushAsync(CancellationToken ct)
{
if (_buffer.Count == 0)
{
return;
}
var rows = _buffer;
using (var group = _writer.CreateRowGroup())
{
await group.WriteAsync(EventIdField, rows.Select(r => r.EventId.ToString("D")).ToArray()).ConfigureAwait(false);
await group.WriteAsync<DateTime>(OccurredAtField, rows.Select(r => r.OccurredAtUtc).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync<long>(OccurredAtTicksField, rows.Select(r => r.OccurredAtUtc.Ticks).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync(ActorField, rows.Select(r => r.Actor).ToArray()!).ConfigureAwait(false);
await group.WriteAsync(ActionField, rows.Select(r => r.Action).ToArray()).ConfigureAwait(false);
await group.WriteAsync(OutcomeField, rows.Select(r => r.Outcome).ToArray()).ConfigureAwait(false);
await group.WriteAsync(CategoryField, rows.Select(r => r.Category).ToArray()).ConfigureAwait(false);
await group.WriteAsync(TargetField, rows.Select(r => r.Target).ToArray()!).ConfigureAwait(false);
await group.WriteAsync(SourceNodeField, rows.Select(r => r.SourceNode).ToArray()!).ConfigureAwait(false);
await group.WriteAsync(CorrelationIdField, rows.Select(r => r.CorrelationId?.ToString("D")).ToArray()!).ConfigureAwait(false);
await group.WriteAsync(DetailsJsonField, rows.Select(r => r.DetailsJson).ToArray()!).ConfigureAwait(false);
await group.WriteAsync<long>(ChainSeqField, rows.Select(r => r.ChainSeq).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync(RowHashField, rows.Select(r => r.RowHash).ToArray()!).ConfigureAwait(false);
}
_buffer.Clear();
}
}
/// <summary>Buffers tombstones and writes them a row group at a time.</summary>
public sealed class TombstoneWriter : IAsyncDisposable
{
private readonly ParquetWriter _writer;
private readonly int _rowGroupSize;
private readonly List<AuditArchiveTombstone> _buffer = [];
private bool _disposed;
internal TombstoneWriter(ParquetWriter writer, int rowGroupSize)
{
_writer = writer;
_rowGroupSize = Math.Max(1, rowGroupSize);
}
/// <summary>Tombstones added so far.</summary>
public long Count { get; private set; }
/// <summary>Adds one tombstone, flushing a row group when the buffer is full.</summary>
/// <param name="tombstone">The tombstone.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that completes when the tombstone is buffered (or its row group written).</returns>
public async Task AddAsync(AuditArchiveTombstone tombstone, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tombstone);
_buffer.Add(tombstone);
Count++;
if (_buffer.Count >= _rowGroupSize)
{
await FlushAsync(ct).ConfigureAwait(false);
}
}
/// <summary>Writes any buffered tombstones as a row group, then the file footer.</summary>
/// <returns>A task that completes when the file is finished.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await FlushAsync(CancellationToken.None).ConfigureAwait(false);
await _writer.DisposeAsync().ConfigureAwait(false);
}
private async Task FlushAsync(CancellationToken ct)
{
if (_buffer.Count == 0)
{
return;
}
var rows = _buffer;
using (var group = _writer.CreateRowGroup())
{
await group.WriteAsync(TombstoneMonthField, rows.Select(t => AuditChainHasher.PartitionLabel(t.ChainMonth)).ToArray()).ConfigureAwait(false);
await group.WriteAsync<long>(TombstoneSeqField, rows.Select(t => t.ChainSeq).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync(TombstoneHashField, rows.Select(t => t.RowHash).ToArray()).ConfigureAwait(false);
await group.WriteAsync(TombstoneEventIdField, rows.Select(t => t.EventId.ToString("D")).ToArray()).ConfigureAwait(false);
await group.WriteAsync<DateTime>(TombstonePurgedAtField, rows.Select(t => t.PurgedAtUtc).ToArray(), cancellationToken: ct).ConfigureAwait(false);
}
_buffer.Clear();
}
}
}
/// <summary>
/// A forward-only write-through stream that computes the SHA-256 and length of everything
/// written to the inner stream, so an archive file is hashed in the same pass that writes it.
/// </summary>
internal sealed class HashingWriteStream : Stream
{
private readonly Stream _inner;
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
public HashingWriteStream(Stream inner) => _inner = inner;
public long BytesWritten { get; private set; }
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => BytesWritten;
set => throw new NotSupportedException();
}
/// <summary>Returns the lowercase-hex SHA-256 of everything written so far (call once, at the end).</summary>
public string HashHex()
{
var hex = Convert.ToHexStringLower(_hash.GetHashAndReset());
_hash.Dispose();
return hex;
}
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
public override void Write(ReadOnlySpan<byte> buffer)
{
_hash.AppendData(buffer);
_inner.Write(buffer);
BytesWritten += buffer.Length;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
_hash.AppendData(buffer.Span);
await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
BytesWritten += buffer.Length;
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override void Flush() => _inner.Flush();
public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken);
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
// The hash is NOT released on Dispose: the Parquet writer may dispose this stream when it
// finishes, and HashHex() is read after that. HashHex() releases it.
}
@@ -0,0 +1,46 @@
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// Options for the AuditLog Parquet cold archive (section <c>AuditLog:Archive</c>; audit
/// roadmap T2, alog.md §15.2, ScadaBridge#42). Validated at startup by
/// <c>AuditLogArchiveOptionsValidator</c> in the AuditLog component.
/// </summary>
/// <remarks>
/// <para>
/// <b>Default OFF.</b> With <see cref="Enabled"/> false the retention purge drops expired
/// partitions exactly as before. With it true the purge writes each expired partition to
/// Parquet under <see cref="Directory"/> first and refuses to drop a partition it could not
/// archive — so a full or unreachable archive volume stops the purge (and the table grows)
/// rather than losing audit rows.
/// </para>
/// <para>
/// <b>Directory.</b> An absolute path both central nodes can reach (a shared volume or UNC
/// share): the purge runs on whichever node is active, and <c>audit archive list/verify</c>
/// run on whichever node serves the management call. Files land in a <c>YYYY</c>
/// sub-directory per partition year. Object storage (S3/Azure Blob) is not wired — mount it
/// as a file system or copy the files on from there.
/// </para>
/// </remarks>
public sealed class AuditLogArchiveOptions
{
/// <summary>Whether the retention purge archives each partition before dropping it (default false).</summary>
public bool Enabled { get; set; }
/// <summary>Absolute archive root directory; required when <see cref="Enabled"/>.</summary>
public string? Directory { get; set; }
/// <summary>Rows per Parquet row group (default 50,000; bounds archive-writer memory).</summary>
public int RowGroupSize { get; set; } = 50_000;
/// <summary>
/// Command timeout, in minutes, for reading one partition out of SQL Server (default 60).
/// A month on a busy central is millions of rows.
/// </summary>
public int ReadCommandTimeoutMinutes { get; set; } = 60;
/// <summary>
/// How long, in seconds, archiving waits for a chaining pass or another purge to release
/// the hash-chain lock before giving up for this tick (default 300).
/// </summary>
public int ChainLockTimeoutSeconds { get; set; } = 300;
}
@@ -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)
{
@@ -0,0 +1,635 @@
using System.Data;
using System.Data.Common;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// SQL Server + file-system implementation of <see cref="IAuditLogArchive"/> (audit roadmap
/// T2, alog.md §15.2, ScadaBridge#42). See the interface for the archive-before-purge
/// contract and <see cref="AuditArchiveParquet"/> for the file layout.
/// </summary>
/// <remarks>
/// <para>
/// <b>Layout.</b> <c>{Directory}/{YYYY}/{name}.parquet</c> (rows),
/// <c>{name}.tombstones.parquet</c> (per-channel purge tombstones) and
/// <c>{name}.manifest.json</c>, where <c>name</c> is
/// <c>auditlog-{YYYY-MM}-{archivedAt:yyyyMMddTHHmmssfff}Z</c>. Each file is written to a
/// <c>.tmp</c> sibling, flushed to disk and renamed; the manifest goes last, so a manifest
/// only ever names complete files. Archives are never overwritten: a retried purge writes a
/// new archive, and the superseded one keeps status <see cref="AuditArchiveStatus.Archived"/>.
/// </para>
/// <para>
/// <b>Consistency with the hash chain.</b> The partition is read inside one transaction that
/// holds the chain lock SHARED, so no chaining pass or purge moves a head or writes a
/// tombstone while the rows, heads and tombstones are read. The purge then re-checks the
/// row count and heads under the EXCLUSIVE lock (<see cref="AuditPartitionSwitchGuard"/>).
/// </para>
/// </remarks>
public sealed partial class AuditLogPartitionArchive : IAuditLogArchive
{
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
private readonly ScadaBridgeDbContext _context;
private readonly AuditLogArchiveOptions _options;
private readonly ILogger<AuditLogPartitionArchive> _logger;
private readonly TimeProvider _time;
/// <summary>Initializes a new instance of the <see cref="AuditLogPartitionArchive"/> class.</summary>
/// <param name="context">The database context whose connection is used.</param>
/// <param name="options">Archive options.</param>
/// <param name="logger">Optional logger.</param>
/// <param name="time">Optional clock (tests).</param>
public AuditLogPartitionArchive(
ScadaBridgeDbContext context,
AuditLogArchiveOptions options,
ILogger<AuditLogPartitionArchive>? logger = null,
TimeProvider? time = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<AuditLogPartitionArchive>.Instance;
_time = time ?? TimeProvider.System;
}
/// <inheritdoc />
public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.Directory);
/// <inheritdoc />
public bool IsEnabled => _options.Enabled && IsConfigured;
/// <summary>Returns true when <paramref name="name"/> is a well-formed archive name.</summary>
/// <param name="name">The candidate name.</param>
/// <returns>True for <c>auditlog-YYYY-MM-yyyyMMddTHHmmssfffZ</c>.</returns>
public static bool IsValidName(string? name) => name is not null && NamePattern().IsMatch(name);
/// <inheritdoc />
public async Task<AuditArchiveManifest> ArchivePartitionAsync(DateTime partitionBoundaryUtc, CancellationToken ct = default)
{
if (!_options.Enabled || !IsConfigured)
{
throw new InvalidOperationException("AuditLog archival is not enabled (AuditLog:Archive:Enabled / Directory).");
}
var boundary = DateTime.SpecifyKind(partitionBoundaryUtc, DateTimeKind.Utc);
var partition = AuditChainHasher.PartitionLabel(AuditChainHasher.MonthOf(boundary));
var archivedAt = _time.GetUtcNow().UtcDateTime;
var name = $"auditlog-{partition}-{archivedAt.ToString("yyyyMMdd'T'HHmmssfff", CultureInfo.InvariantCulture)}Z";
var directory = Path.Combine(_options.Directory!, partition[..4]);
Directory.CreateDirectory(directory);
var dataFile = name + ".parquet";
var tombstoneFile = name + ".tombstones.parquet";
var dataTmp = Path.Combine(directory, dataFile + ".tmp");
var tombstoneTmp = Path.Combine(directory, tombstoneFile + ".tmp");
var chains = new List<AuditArchiveChainState>();
long rowCount = 0, chained = 0;
DateTime? minOccurred = null, maxOccurred = null;
string dataSha, tombstoneSha;
long dataBytes, tombstoneCount;
try
{
var conn = _context.Database.GetDbConnection();
var openedHere = await AuditLogHashChain.OpenAsync(conn, ct).ConfigureAwait(false);
try
{
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
if (!await AuditLogHashChain.TryAcquireLockAsync(
conn, tx, "Shared", TimeSpan.FromSeconds(Math.Max(1, _options.ChainLockTimeoutSeconds)), ct)
.ConfigureAwait(false))
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
throw new TimeoutException(
$"The AuditLog hash-chain lock stayed busy for {_options.ChainLockTimeoutSeconds}s; partition {partition} not archived this tick.");
}
// 1. Chain heads of every month the partition holds (normally exactly one).
var tombstoneCounts = new Dictionary<DateTime, long>();
var heads = new List<(DateTime Month, long LastSeq, byte[] LastHash, long? PurgedSeq, byte[]? PurgedHash)>();
await using (var cmd = Command(conn, tx,
"SELECT ChainMonth, LastSeq, LastHash, PurgedThroughSeq, PurgedThroughHash FROM dbo.AuditLogChainHead " +
"WHERE $partition.pf_AuditLog_Month(CAST(ChainMonth AS datetime2(7))) = $partition.pf_AuditLog_Month(@boundary) " +
"ORDER BY ChainMonth;", boundary))
{
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
heads.Add((
DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc),
reader.GetInt64(1),
(byte[])reader.GetValue(2),
reader.IsDBNull(3) ? null : reader.GetInt64(3),
reader.IsDBNull(4) ? null : (byte[])reader.GetValue(4)));
}
}
// 2. Per-channel purge tombstones (verification bridges gaps with them).
await using (var file = NewFile(tombstoneTmp))
{
var hashing = new HashingWriteStream(file);
await using (var writer = await AuditArchiveParquet
.CreateTombstoneWriterAsync(hashing, partition, _options.RowGroupSize, ct).ConfigureAwait(false))
{
await using var cmd = Command(conn, tx,
"SELECT ChainMonth, ChainSeq, RowHash, EventId, PurgedAtUtc FROM dbo.AuditLogChainTombstone " +
"WHERE $partition.pf_AuditLog_Month(CAST(ChainMonth AS datetime2(7))) = $partition.pf_AuditLog_Month(@boundary) " +
"ORDER BY ChainMonth, ChainSeq;", boundary);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var tombstone = new AuditArchiveTombstone(
DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc),
reader.GetInt64(1),
(byte[])reader.GetValue(2),
reader.GetGuid(3),
DateTime.SpecifyKind(reader.GetDateTime(4), DateTimeKind.Utc));
tombstoneCounts[tombstone.ChainMonth] = tombstoneCounts.GetValueOrDefault(tombstone.ChainMonth) + 1;
await writer.AddAsync(tombstone, ct).ConfigureAwait(false);
}
tombstoneCount = writer.Count;
}
await hashing.FlushAsync(ct).ConfigureAwait(false);
file.Flush(flushToDisk: true);
tombstoneSha = hashing.HashHex();
}
// 3. The rows, grouped by chain month and in chain order within it (unchained
// rows last), which is the order verification needs.
await using (var file = NewFile(dataTmp))
{
var hashing = new HashingWriteStream(file);
await using (var writer = await AuditArchiveParquet
.CreateDataWriterAsync(hashing, partition, _options.RowGroupSize, ct).ConfigureAwait(false))
{
await using var cmd = Command(conn, tx,
$"SELECT {AuditLogHashChain.RowColumns}, ChainSeq, RowHash FROM dbo.AuditLog " +
"WHERE $PARTITION.pf_AuditLog_Month(OccurredAtUtc) = $partition.pf_AuditLog_Month(@boundary) " +
"ORDER BY DATEFROMPARTS(YEAR(OccurredAtUtc), MONTH(OccurredAtUtc), 1), " +
"CASE WHEN ChainSeq IS NULL THEN 1 ELSE 0 END, ChainSeq, OccurredAtUtc, EventId;", boundary);
cmd.CommandTimeout = Math.Max(1, _options.ReadCommandTimeoutMinutes) * 60;
await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess, ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var row = AuditLogHashChain.ReadRow(reader, withLink: true);
if (row.ChainSeq is not null)
{
chained++;
}
minOccurred = minOccurred is { } lo && lo <= row.OccurredAtUtc ? lo : row.OccurredAtUtc;
maxOccurred = maxOccurred is { } hi && hi >= row.OccurredAtUtc ? hi : row.OccurredAtUtc;
await writer.AddAsync(row, ct).ConfigureAwait(false);
}
rowCount = writer.RowCount;
}
await hashing.FlushAsync(ct).ConfigureAwait(false);
file.Flush(flushToDisk: true);
dataSha = hashing.HashHex();
dataBytes = hashing.BytesWritten;
}
await tx.CommitAsync(ct).ConfigureAwait(false);
foreach (var head in heads)
{
chains.Add(new AuditArchiveChainState(
AuditChainHasher.PartitionLabel(head.Month),
head.LastSeq,
Convert.ToHexStringLower(head.LastHash),
head.PurgedSeq,
head.PurgedHash is null ? null : Convert.ToHexStringLower(head.PurgedHash),
tombstoneCounts.GetValueOrDefault(head.Month)));
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
File.Move(dataTmp, Path.Combine(directory, dataFile));
File.Move(tombstoneTmp, Path.Combine(directory, tombstoneFile));
}
catch
{
TryDelete(dataTmp);
TryDelete(tombstoneTmp);
throw;
}
var manifest = new AuditArchiveManifest(
AuditArchiveParquet.FormatVersion,
name,
boundary,
partition,
archivedAt,
AuditArchiveStatus.Archived,
PurgedAtUtc: null,
rowCount,
chained,
rowCount - chained,
minOccurred,
maxOccurred,
dataFile,
dataSha,
dataBytes,
tombstoneFile,
tombstoneSha,
tombstoneCount,
chains);
await WriteManifestAsync(directory, manifest, ct).ConfigureAwait(false);
_logger.LogInformation(
"Archived AuditLog partition {Partition} to {Archive}: {Rows} rows ({Chained} chained), {Tombstones} tombstones, {Bytes} bytes.",
partition, name, rowCount, chained, tombstoneCount, dataBytes);
return manifest;
}
/// <inheritdoc />
public async Task<long> PurgeArchivedPartitionAsync(
AuditArchiveManifest manifest, TimeSpan? commandTimeout = null, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(manifest);
var guard = new AuditPartitionSwitchGuard(
manifest.RowCount,
manifest.Chains.Select(c => (AuditArchiveParquet.ParseMonth(c.Partition), c.LastSeq)).ToList());
long purged;
try
{
purged = await new AuditLogRepository(_context)
.SwitchOutPartitionAsync(manifest.PartitionBoundaryUtc, guard, commandTimeout, ct)
.ConfigureAwait(false);
}
catch (Exception ex) when (FindSqlError(ex) is { } number
&& number is AuditPartitionSwitchGuard.RowCountChangedError or AuditPartitionSwitchGuard.ChainAdvancedError)
{
throw new AuditArchiveStaleException(
$"Partition {manifest.Partition} changed after archive {manifest.Name} was written; nothing was purged, "
+ "the next purge tick archives it again.", ex);
}
try
{
var directory = Path.Combine(_options.Directory!, manifest.Partition[..4]);
await WriteManifestAsync(directory, manifest with
{
Status = AuditArchiveStatus.Purged,
PurgedAtUtc = _time.GetUtcNow().UtcDateTime,
}, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// The rows are gone and the archive files are complete; only the status flag is
// stale. Verify still ties the archive to the purge anchor (anchor Matches).
_logger.LogError(ex,
"AuditLog partition {Partition} was purged but archive {Archive}'s manifest could not be marked Purged.",
manifest.Partition, manifest.Name);
}
return purged;
}
/// <inheritdoc />
public async Task<IReadOnlyList<AuditArchiveManifest>> ListAsync(CancellationToken ct = default)
{
RequireConfigured();
if (!Directory.Exists(_options.Directory))
{
return [];
}
var manifests = new List<AuditArchiveManifest>();
foreach (var path in Directory.EnumerateFiles(_options.Directory!, "*.manifest.json", SearchOption.AllDirectories))
{
if (!IsValidName(Path.GetFileName(path)[..^".manifest.json".Length]))
{
continue;
}
try
{
manifests.Add(await ReadManifestAsync(path, ct).ConfigureAwait(false));
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Skipping unreadable AuditLog archive manifest {Path}.", path);
}
}
return manifests
.OrderBy(m => m.PartitionBoundaryUtc)
.ThenBy(m => m.ArchivedAtUtc)
.ToList();
}
/// <inheritdoc />
public async Task<AuditArchiveVerificationResult> VerifyAsync(string name, CancellationToken ct = default)
{
RequireConfigured();
if (!IsValidName(name))
{
throw new ArgumentException(
$"'{name}' is not an archive name (expected auditlog-YYYY-MM-yyyyMMddTHHmmssfffZ).", nameof(name));
}
var directory = Path.Combine(_options.Directory!, name.Substring("auditlog-".Length, 4));
var manifestPath = Path.Combine(directory, name + ".manifest.json");
if (!File.Exists(manifestPath))
{
throw new FileNotFoundException($"No AuditLog archive named '{name}'.", manifestPath);
}
var manifest = await ReadManifestAsync(manifestPath, ct).ConfigureAwait(false);
var problems = new List<string>();
var dataPath = Path.Combine(directory, Path.GetFileName(manifest.DataFile));
var tombstonePath = Path.Combine(directory, Path.GetFileName(manifest.TombstoneFile));
await CheckFileAsync(dataPath, manifest.DataSha256, manifest.DataBytes, problems, ct).ConfigureAwait(false);
await CheckFileAsync(tombstonePath, manifest.TombstoneSha256, null, problems, ct).ConfigureAwait(false);
if (problems.Count > 0)
{
return new AuditArchiveVerificationResult(
manifest.Name, manifest.Partition, manifest.Status, Verified: false, FilesIntact: false,
string.Join(" ", problems), RowsRead: 0, []);
}
// Tombstones per month, then one streaming verifier per chain month.
var tombstones = new Dictionary<DateTime, Dictionary<long, byte[]>>();
long tombstonesRead = 0;
await using (var input = File.OpenRead(tombstonePath))
{
await foreach (var t in AuditArchiveParquet.ReadTombstonesAsync(input, ct).ConfigureAwait(false))
{
if (!tombstones.TryGetValue(t.ChainMonth, out var byMonth))
{
tombstones[t.ChainMonth] = byMonth = [];
}
byMonth[t.ChainSeq] = t.RowHash;
tombstonesRead++;
}
}
var states = manifest.Chains.ToDictionary(c => AuditArchiveParquet.ParseMonth(c.Partition));
var verifiers = new SortedDictionary<DateTime, AuditChainVerifier>();
var unchained = new Dictionary<DateTime, long>();
AuditChainVerifier VerifierFor(DateTime month)
{
if (!verifiers.TryGetValue(month, out var verifier))
{
AuditChainHeadState? head = states.TryGetValue(month, out var s)
? new AuditChainHeadState(
s.LastSeq,
Convert.FromHexString(s.LastHash),
s.PriorPurgedThroughSeq,
s.PriorPurgedThroughHash is null ? null : Convert.FromHexString(s.PriorPurgedThroughHash))
: null;
verifier = new AuditChainVerifier(month, head, tombstones.GetValueOrDefault(month) ?? []);
verifiers[month] = verifier;
}
return verifier;
}
foreach (var month in states.Keys)
{
VerifierFor(month);
}
long rowsRead = 0;
await using (var input = File.OpenRead(dataPath))
{
await foreach (var row in AuditArchiveParquet.ReadRowsAsync(input, ct).ConfigureAwait(false))
{
rowsRead++;
var month = AuditChainHasher.MonthOf(row.OccurredAtUtc);
if (row.ChainSeq is null)
{
unchained[month] = unchained.GetValueOrDefault(month) + 1;
VerifierFor(month);
continue;
}
VerifierFor(month).Accept(row);
}
}
if (rowsRead != manifest.RowCount)
{
problems.Add($"The data file holds {rowsRead} rows; the manifest records {manifest.RowCount}.");
}
if (tombstonesRead != manifest.TombstoneCount)
{
problems.Add($"The tombstone file holds {tombstonesRead} tombstones; the manifest records {manifest.TombstoneCount}.");
}
var anchors = await ReadAnchorsAsync(verifiers.Keys, ct).ConfigureAwait(false);
var results = new List<AuditArchiveChainVerification>();
foreach (var (month, verifier) in verifiers)
{
var chain = verifier.Complete(unchained.GetValueOrDefault(month));
var (anchor, detail) = CompareAnchor(states.GetValueOrDefault(month), anchors.GetValueOrDefault(month));
results.Add(new AuditArchiveChainVerification(chain, anchor, detail));
}
var filesIntact = problems.Count == 0;
var verified = filesIntact
&& results.All(r => r.Chain.Status != AuditChainStatus.Broken && r.Anchor != AuditArchiveAnchorStatus.Mismatch);
return new AuditArchiveVerificationResult(
manifest.Name, manifest.Partition, manifest.Status, verified, filesIntact,
filesIntact ? null : string.Join(" ", problems), rowsRead, results);
}
/// <summary>
/// Compares an archived chain end with the database's current checkpoint anchor for the month.
/// </summary>
internal static (AuditArchiveAnchorStatus Status, string Detail) CompareAnchor(
AuditArchiveChainState? archived, (bool HeadExists, long? PurgedSeq, byte[]? PurgedHash) database)
{
if (archived is null)
{
// No chain head existed when the rows were archived (chaining off, or the month was
// never reached): nothing to tie. A later anchor can only come from a later purge.
return database.PurgedSeq is null
? (AuditArchiveAnchorStatus.Matches, "No hash chain existed for this month when it was archived.")
: (AuditArchiveAnchorStatus.Superseded,
$"No chain at archive time; a later purge anchored the month at ChainSeq {database.PurgedSeq}.");
}
if (!database.HeadExists)
{
return (AuditArchiveAnchorStatus.Mismatch,
$"The archive records a chain through ChainSeq {archived.LastSeq} but the database has no chain head for {archived.Partition}.");
}
if (database.PurgedSeq is null)
{
return (AuditArchiveAnchorStatus.NotPurged,
$"{archived.Partition} has not been purged: the archived rows are still in dbo.AuditLog.");
}
if (database.PurgedSeq == archived.LastSeq)
{
return database.PurgedHash is not null
&& Convert.ToHexStringLower(database.PurgedHash) == archived.LastHash.ToLowerInvariant()
? (AuditArchiveAnchorStatus.Matches,
$"The archived chain ends at the purge anchor (ChainSeq {archived.LastSeq}).")
: (AuditArchiveAnchorStatus.Mismatch,
$"The purge anchor is at ChainSeq {archived.LastSeq} but its hash differs from the archive's chain end.");
}
return database.PurgedSeq > archived.LastSeq
? (AuditArchiveAnchorStatus.Superseded,
$"The purge anchor (ChainSeq {database.PurgedSeq}) is past this archive's chain end ({archived.LastSeq}): "
+ "late rows were purged and archived again afterwards.")
: (AuditArchiveAnchorStatus.Mismatch,
$"The purge anchor (ChainSeq {database.PurgedSeq}) is before this archive's chain end ({archived.LastSeq}).");
}
private async Task<Dictionary<DateTime, (bool HeadExists, long? PurgedSeq, byte[]? PurgedHash)>> ReadAnchorsAsync(
IEnumerable<DateTime> months, CancellationToken ct)
{
var result = new Dictionary<DateTime, (bool, long?, byte[]?)>();
var conn = _context.Database.GetDbConnection();
var openedHere = await AuditLogHashChain.OpenAsync(conn, ct).ConfigureAwait(false);
try
{
foreach (var month in months)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT PurgedThroughSeq, PurgedThroughHash FROM dbo.AuditLogChainHead WHERE ChainMonth = @month;";
cmd.Parameters.Add(new SqlParameter("@month", SqlDbType.Date) { Value = month.Date });
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
result[month] = await reader.ReadAsync(ct).ConfigureAwait(false)
? (true,
reader.IsDBNull(0) ? null : reader.GetInt64(0),
reader.IsDBNull(1) ? null : (byte[])reader.GetValue(1))
: (false, null, null);
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return result;
}
private static async Task CheckFileAsync(string path, string expectedSha, long? expectedBytes, List<string> problems, CancellationToken ct)
{
if (!File.Exists(path))
{
problems.Add($"{Path.GetFileName(path)} is missing.");
return;
}
await using var input = File.OpenRead(path);
if (expectedBytes is { } bytes && input.Length != bytes)
{
problems.Add($"{Path.GetFileName(path)} is {input.Length} bytes; the manifest records {bytes}.");
return;
}
var actual = Convert.ToHexStringLower(await SHA256.HashDataAsync(input, ct).ConfigureAwait(false));
if (!string.Equals(actual, expectedSha, StringComparison.OrdinalIgnoreCase))
{
problems.Add($"{Path.GetFileName(path)}'s SHA-256 does not match the manifest: the file was altered.");
}
}
private static async Task WriteManifestAsync(string directory, AuditArchiveManifest manifest, CancellationToken ct)
{
var path = Path.Combine(directory, manifest.Name + ".manifest.json");
var tmp = path + ".tmp";
await using (var file = NewFile(tmp))
{
await JsonSerializer.SerializeAsync(file, manifest, ManifestJson, ct).ConfigureAwait(false);
file.Flush(flushToDisk: true);
}
File.Move(tmp, path, overwrite: true);
}
private static async Task<AuditArchiveManifest> ReadManifestAsync(string path, CancellationToken ct)
{
await using var input = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<AuditArchiveManifest>(input, ManifestJson, ct).ConfigureAwait(false)
?? throw new JsonException($"Empty manifest {path}.");
}
private static FileStream NewFile(string path)
=> new(path, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 1 << 16, FileOptions.Asynchronous);
private static DbCommand Command(DbConnection conn, DbTransaction tx, string sql, DateTime boundary)
{
var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.Parameters.Add(new SqlParameter("@boundary", SqlDbType.DateTime2) { Scale = 7, Value = boundary });
return cmd;
}
private static int? FindSqlError(Exception? ex)
{
for (; ex is not null; ex = ex.InnerException)
{
if (ex is SqlException sql)
{
return sql.Number;
}
}
return null;
}
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
private void RequireConfigured()
{
if (!IsConfigured)
{
throw new InvalidOperationException("No AuditLog archive directory is configured (AuditLog:Archive:Directory).");
}
}
[GeneratedRegex(@"^auditlog-\d{4}-(0[1-9]|1[0-2])-\d{8}T\d{9}Z$", RegexOptions.CultureInvariant)]
private static partial Regex NamePattern();
}
@@ -0,0 +1,56 @@
using System.Globalization;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// What an archive captured about a partition (ScadaBridge#42), checked inside the
/// partition-switch transaction so the purge drops exactly the rows the archive holds.
/// </summary>
/// <param name="ExpectedRows">Rows the archive holds for the partition.</param>
/// <param name="ExpectedHeads">Each chain month's <c>LastSeq</c> at archive time.</param>
public sealed record AuditPartitionSwitchGuard(long ExpectedRows, IReadOnlyList<(DateTime ChainMonth, long LastSeq)> ExpectedHeads)
{
/// <summary>SQL error number THROWn when the partition row count changed.</summary>
public const int RowCountChangedError = 51002;
/// <summary>SQL error number THROWn when a chain head in the partition changed.</summary>
public const int ChainAdvancedError = 51003;
/// <summary>
/// Renders the guard as T-SQL for the switch batch. Every value is a number or a
/// <c>yyyy-MM-dd</c> date produced here, so inlining them as literals is injection-safe and
/// keeps the switch a parameterless <c>ExecuteSqlRaw</c> batch.
/// </summary>
/// <param name="monthBoundaryLiteral">The boundary literal the switch batch already uses.</param>
/// <returns>The guard statements (run after the chain lock, inside the TRY).</returns>
internal string ToSql(string monthBoundaryLiteral)
{
var sql = new StringBuilder();
sql.AppendLine();
sql.AppendLine(" -- 0b. Archive guard (ScadaBridge#42): the archive must hold exactly the rows this");
sql.AppendLine(" -- switch drops. Under the exclusive chain lock, so nothing chains or per-channel");
sql.AppendLine(" -- purges between this check and the SWITCH.");
sql.AppendLine(CultureInfo.InvariantCulture, $" DECLARE @guardPartition int = $partition.pf_AuditLog_Month('{monthBoundaryLiteral}');");
sql.AppendLine(" IF (SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE $PARTITION.pf_AuditLog_Month(OccurredAtUtc) = @guardPartition)");
sql.AppendLine(CultureInfo.InvariantCulture, $" <> {ExpectedRows}");
sql.AppendLine(CultureInfo.InvariantCulture, $" THROW {RowCountChangedError}, 'AuditLog archive is stale: the partition row count changed after it was archived.', 1;");
sql.AppendLine(" DECLARE @guardHeads TABLE (ChainMonth date NOT NULL PRIMARY KEY, LastSeq bigint NOT NULL);");
foreach (var (month, lastSeq) in ExpectedHeads)
{
sql.AppendLine(CultureInfo.InvariantCulture,
$" INSERT INTO @guardHeads (ChainMonth, LastSeq) VALUES ('{month:yyyy-MM-dd}', {lastSeq});");
}
sql.AppendLine(" IF EXISTS (");
sql.AppendLine(" SELECT h.ChainMonth, h.LastSeq FROM dbo.AuditLogChainHead h");
sql.AppendLine(" WHERE $partition.pf_AuditLog_Month(CAST(h.ChainMonth AS datetime2(7))) = @guardPartition");
sql.AppendLine(" EXCEPT SELECT ChainMonth, LastSeq FROM @guardHeads)");
sql.AppendLine(" OR EXISTS (");
sql.AppendLine(" SELECT ChainMonth, LastSeq FROM @guardHeads");
sql.AppendLine(" EXCEPT SELECT h.ChainMonth, h.LastSeq FROM dbo.AuditLogChainHead h");
sql.AppendLine(" WHERE $partition.pf_AuditLog_Month(CAST(h.ChainMonth AS datetime2(7))) = @guardPartition)");
sql.AppendLine(CultureInfo.InvariantCulture, $" THROW {ChainAdvancedError}, 'AuditLog archive is stale: the hash chain advanced after it was archived.', 1;");
return sql.ToString();
}
}
@@ -477,7 +477,24 @@ VALUES
};
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
=> SwitchOutPartitionAsync(monthBoundary, guard: null, commandTimeout, ct);
/// <summary>
/// The partition switch-out, optionally guarded by an archive's expectations
/// (ScadaBridge#42). With a <paramref name="guard"/>, the switch transaction — after it
/// holds the exclusive chain lock — re-counts the partition and re-reads its months'
/// chain heads, and THROWs (error 51002 / 51003, nothing purged) when either differs
/// from what the archive captured. That is what makes "archive, then purge" safe
/// against a row arriving, a per-channel purge or a chaining pass in between.
/// </summary>
/// <param name="monthBoundary">The partition's lower boundary.</param>
/// <param name="guard">Archive expectations, or null for the unguarded purge.</param>
/// <param name="commandTimeout">Maintenance command timeout.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The approximate number of rows discarded.</returns>
internal async Task<long> SwitchOutPartitionAsync(
DateTime monthBoundary, Maintenance.AuditPartitionSwitchGuard? guard, TimeSpan? commandTimeout, CancellationToken ct)
{
// The switch batch below runs via
// ExecuteSqlRaw with NO EF user-transaction — it carries its own server-side
@@ -526,6 +543,11 @@ VALUES
WHERE $PARTITION.pf_AuditLog_Month(OccurredAtUtc) =
$partition.pf_AuditLog_Month('{monthBoundaryStr}');";
// ScadaBridge#42 archive guard (empty for the unguarded purge). Runs under the chain
// lock, so no chaining pass or per-channel purge can move the partition between the
// check and the SWITCH; an INSERT racing it is caught by the next tick's archive.
var guardSql = guard is null ? string.Empty : guard.ToSql(monthBoundaryStr);
var sql = $@"
BEGIN TRY
BEGIN TRANSACTION;
@@ -536,7 +558,7 @@ VALUES
DECLARE @chainLock int;
EXEC @chainLock = sp_getapplock @Resource = N'{Maintenance.AuditChainLock.Resource}', @LockMode = 'Exclusive', @LockOwner = 'Transaction', @LockTimeout = -1;
IF @chainLock < 0 THROW 51000, 'AuditLog hash-chain lock was not granted.', 1;
{guardSql}
-- 1. Defensive cleanup for databases created before
-- AlignAuditLogEventIdUniqueness: the migration drops the
-- non-aligned UX_AuditLog_EventId, but a database restored from an
@@ -114,6 +114,15 @@ public static class ServiceCollectionExtensions
// AuditLogHashChainService, verified by the VerifyAuditChain management command.
services.AddScoped<IAuditLogHashChain, AuditLogHashChain>();
// AuditLog Parquet cold archive (ScadaBridge#42): written by the purge actor before a
// partition switch, listed/verified by the ListAuditArchives / VerifyAuditArchive
// management commands. AuditLog:Archive is bound by AddAuditLogCentral; without it the
// defaults apply (disabled, no directory).
services.AddScoped<IAuditLogArchive>(sp => new AuditLogPartitionArchive(
sp.GetRequiredService<ScadaBridgeDbContext>(),
sp.GetService<Microsoft.Extensions.Options.IOptions<AuditLogArchiveOptions>>()?.Value ?? new AuditLogArchiveOptions(),
sp.GetService<Microsoft.Extensions.Logging.ILogger<AuditLogPartitionArchive>>()));
// Opt-in config-audit retention (ScadaBridge:ConfigAudit:RetentionDays, default 0 = off).
// Options binding to configuration is done by the host; without it the defaults apply
// and the purge service is a no-op.
@@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Parquet.Net" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.Audit;
@@ -688,4 +689,107 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
StopPurgeActor(actor);
}
// ---------------------------------------------------------------------
// Parquet cold archive (ScadaBridge#42)
// ---------------------------------------------------------------------
private IActorRef CreateArchivingActor(RecordingRepo repo, IAuditLogArchive archive, bool enabled)
{
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ => repo);
services.AddScoped(_ => archive);
services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance.AuditLogArchiveOptions
{
Enabled = enabled,
Directory = "/archive",
}));
var sp = services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(FastTickOptions(TimeSpan.FromHours(1))),
Options.Create(new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
}
private static AuditArchiveManifest ManifestFor(DateTime boundary) => new(
1, $"auditlog-{boundary:yyyy-MM}-20261109T120000000Z", boundary, boundary.ToString("yyyy-MM"),
DateTime.UtcNow, AuditArchiveStatus.Archived, null, 7, 7, 0, null, null,
"d.parquet", "00", 99, "t.parquet", "00", 0, []);
/// <summary>
/// With archiving enabled every expired partition is archived and then purged by the
/// archive's guarded purge — never by the plain, unguarded switch.
/// </summary>
[Fact]
public void ArchiveEnabled_ArchivesEachPartition_ThenPurgesThroughTheArchive()
{
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo { Boundaries = [boundary] };
var archive = Substitute.For<IAuditLogArchive>();
var manifest = ManifestFor(boundary);
archive.ArchivePartitionAsync(boundary, Arg.Any<CancellationToken>()).Returns(manifest);
archive.PurgeArchivedPartitionAsync(manifest, Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns(7L);
var archived = CreateTestProbe();
Sys.EventStream.Subscribe(archived.Ref, typeof(AuditLogArchivedEvent));
var purged = SubscribePurged();
var actor = CreateArchivingActor(repo, archive, enabled: true);
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
var a = archived.ExpectMsg<AuditLogArchivedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal(manifest.Name, a.ArchiveName);
Assert.Equal(7, purged.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5)).RowsDeleted);
Assert.Empty(repo.SwitchedBoundaries);
archive.Received(1).PurgeArchivedPartitionAsync(manifest, Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
StopPurgeActor(actor);
}
/// <summary>
/// Fail closed: a partition that could not be archived is not purged at all, and the
/// failure surfaces as a purge failure.
/// </summary>
[Fact]
public void ArchiveFailure_LeavesThePartitionInPlace_AndPublishesAFailure()
{
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo { Boundaries = [boundary] };
var archive = Substitute.For<IAuditLogArchive>();
archive.ArchivePartitionAsync(boundary, Arg.Any<CancellationToken>())
.Returns<Task<AuditArchiveManifest>>(_ => throw new IOException("archive volume full"));
var failed = CreateTestProbe();
Sys.EventStream.Subscribe(failed.Ref, typeof(AuditLogPurgeFailedEvent));
var actor = CreateArchivingActor(repo, archive, enabled: true);
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
var evt = failed.ExpectMsg<AuditLogPurgeFailedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal(boundary, evt.MonthBoundary);
Assert.Contains("archive volume full", evt.Error);
Assert.Empty(repo.SwitchedBoundaries);
archive.DidNotReceiveWithAnyArgs().PurgeArchivedPartitionAsync(default!, default, default);
StopPurgeActor(actor);
}
/// <summary>With archiving configured but disabled, the purge is the plain switch and the archive is untouched.</summary>
[Fact]
public void ArchiveDisabled_UsesThePlainSwitch()
{
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo { Boundaries = [boundary] };
var archive = Substitute.For<IAuditLogArchive>();
var purged = SubscribePurged();
var actor = CreateArchivingActor(repo, archive, enabled: false);
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
purged.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal([boundary], repo.SwitchedBoundaries);
archive.DidNotReceiveWithAnyArgs().ArchivePartitionAsync(default, default);
StopPurgeActor(actor);
}
}
@@ -0,0 +1,164 @@
using Parquet;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Storage-free tests for the AuditLog archive Parquet layout (ScadaBridge#42): rows and
/// tombstones round-trip exactly (tick-precise timestamps, NULL vs empty strings, unicode,
/// the chain columns), the archived rows re-verify against the archived chain head, and an
/// edit made inside the Parquet file breaks that verification.
/// </summary>
public class AuditArchiveParquetTests
{
private static readonly DateTime Month = new(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Rows written to the archive layout read back equal, column for column, across row groups.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rows_RoundTrip_Exactly_AcrossRowGroups()
{
var (rows, _) = BuildChain(2_503);
rows.Add(Row(9_999) with { ChainSeq = null, RowHash = null }); // an unchained row
var bytes = await WriteAsync(rows, rowGroupSize: 1_000);
var back = await ReadAsync(bytes);
Assert.Equal(rows.Select(Key), back.Select(Key));
await using var reader = await ParquetReader.CreateAsync(new MemoryStream(bytes));
Assert.Equal(3, reader.RowGroupCount);
Assert.Equal("1", reader.CustomMetadata[AuditArchiveParquet.FormatVersionKey]);
Assert.Equal("2025-10", reader.CustomMetadata[AuditArchiveParquet.PartitionKey]);
}
/// <summary>Tombstones round-trip exactly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Tombstones_RoundTrip_Exactly()
{
var tombstones = Enumerable.Range(1, 5).Select(i => new AuditArchiveTombstone(
Month, i * 3, Enumerable.Repeat((byte)i, 32).ToArray(), Guid.NewGuid(),
new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddMilliseconds(i))).ToList();
var stream = new MemoryStream();
await using (var writer = await AuditArchiveParquet.CreateTombstoneWriterAsync(stream, "2025-10", 2))
{
foreach (var t in tombstones)
{
await writer.AddAsync(t);
}
}
var back = new List<AuditArchiveTombstone>();
await foreach (var t in AuditArchiveParquet.ReadTombstonesAsync(new MemoryStream(stream.ToArray())))
{
back.Add(t);
}
Assert.Equal(
tombstones.Select(t => (t.ChainMonth, t.ChainSeq, Convert.ToHexString(t.RowHash), t.EventId, t.PurgedAtUtc)),
back.Select(t => (t.ChainMonth, t.ChainSeq, Convert.ToHexString(t.RowHash), t.EventId, t.PurgedAtUtc)));
}
/// <summary>
/// The rows read back from an archive re-verify against the archived head — the same
/// check <c>audit archive verify</c> makes long after the database dropped them.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ArchivedRows_ReverifyAgainstTheArchivedHead()
{
var (rows, head) = BuildChain(50);
var verifier = new AuditChainVerifier(Month, head, new Dictionary<long, byte[]>());
foreach (var row in await ReadAsync(await WriteAsync(rows, 16)))
{
verifier.Accept(row);
}
var result = verifier.Complete(0);
Assert.Equal(AuditChainStatus.Intact, result.Status);
Assert.Equal(50, result.RowsVerified);
}
/// <summary>
/// Positive control for the test above: a row edited inside the archive (re-written
/// file, as a forger would) fails re-verification at that row.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RowEditedInsideTheArchive_FailsReverification()
{
var (rows, head) = BuildChain(50);
rows[17] = rows[17] with { DetailsJson = "{\"kind\":\"ApiCall\",\"forged\":true}" };
var verifier = new AuditChainVerifier(Month, head, new Dictionary<long, byte[]>());
foreach (var row in await ReadAsync(await WriteAsync(rows, 16)))
{
verifier.Accept(row);
}
var result = verifier.Complete(0);
Assert.Equal(AuditChainStatus.Broken, result.Status);
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
Assert.Equal(18, result.FirstBreak.ChainSeq);
}
internal static (List<AuditChainRow> Rows, AuditChainHeadState Head) BuildChain(int count)
{
var rows = new List<AuditChainRow>();
var previous = AuditChainHasher.GenesisHash.ToArray();
for (var i = 1; i <= count; i++)
{
var row = Row(i);
var hash = AuditChainHasher.ComputeRowHash(previous, Month, i, row);
rows.Add(row with { ChainSeq = i, RowHash = hash });
previous = hash;
}
return (rows, new AuditChainHeadState(count, previous, null, null));
}
private static AuditChainRow Row(int i) => new(
Guid.NewGuid(),
// Odd ticks on purpose: the archive must keep datetime2(7) precision the hash covers.
Month.AddMinutes(i).AddTicks(1_234_567 + i),
i % 5 == 0 ? null : i % 7 == 0 ? string.Empty : "api-key-" + i,
"ApiOutbound.ApiCall",
i % 3 == 0 ? "Failure" : "Success",
"ApiOutbound",
i % 4 == 0 ? null : "erp-ü-日本",
i % 6 == 0 ? null : "central-a",
i % 2 == 0 ? Guid.NewGuid() : null,
i % 8 == 0 ? null : $"{{\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"n\":{i}}}");
private static string Key(AuditChainRow r) => string.Join('|',
r.EventId, r.OccurredAtUtc.Ticks, r.OccurredAtUtc.Kind, r.Actor ?? "<null>", r.Action, r.Outcome, r.Category,
r.Target ?? "<null>", r.SourceNode ?? "<null>", r.CorrelationId?.ToString() ?? "<null>", r.DetailsJson ?? "<null>",
r.ChainSeq?.ToString() ?? "<null>", r.RowHash is null ? "<null>" : Convert.ToHexString(r.RowHash));
private static async Task<byte[]> WriteAsync(IEnumerable<AuditChainRow> rows, int rowGroupSize)
{
var stream = new MemoryStream();
await using (var writer = await AuditArchiveParquet.CreateDataWriterAsync(stream, "2025-10", rowGroupSize))
{
foreach (var row in rows)
{
await writer.AddAsync(row);
}
}
return stream.ToArray();
}
private static async Task<List<AuditChainRow>> ReadAsync(byte[] bytes)
{
var rows = new List<AuditChainRow>();
await foreach (var row in AuditArchiveParquet.ReadRowsAsync(new MemoryStream(bytes)))
{
rows.Add(row);
}
return rows;
}
}
@@ -0,0 +1,420 @@
using System.Text.Json;
using Microsoft.Data.SqlClient;
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 integration tests for the AuditLog Parquet archive (ScadaBridge#42) against the
/// real migrated, partitioned schema: archive-then-purge leaves the month's hash chain
/// verifiable and the archive re-verifiable against the purge anchor; the purge refuses to
/// drop a partition that changed after it was archived; tampering with an archive is caught.
/// </summary>
/// <remarks>
/// Every test owns a distinct month (partition) of the shared fixture database; the chain
/// and the per-channel purge are table-wide, so each test drains the chain itself.
/// </remarks>
public sealed class AuditLogPartitionArchiveTests : IClassFixture<MsSqlMigrationFixture>, IDisposable
{
private readonly MsSqlMigrationFixture _fixture;
private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("auditlog-archive-");
/// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary>
/// <param name="fixture">The shared migration fixture.</param>
public AuditLogPartitionArchiveTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
/// <inheritdoc />
public void Dispose() => _root.Delete(recursive: true);
/// <summary>
/// Archive, then purge: the archive holds exactly the partition's rows (chained,
/// unchained, with their chain columns) and its tombstones; after the switch the month's
/// chain still verifies from the anchor, and the archive verifies against that anchor.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ArchiveThenPurge_KeepsTheChainVerifiable_AndTheArchiveMatchesTheAnchor()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var month = Month(2026, 2);
await InsertAsync(Enumerable.Range(1, 10).Select(i => NewEvent(month.AddDays(i).AddTicks(i * 7_777))));
// Two rows of a short-retention channel, purged per channel mid-chain → tombstones.
await InsertAsync([NewEvent(month.AddHours(1), AuditChannel.DbOutbound), NewEvent(month.AddHours(2), AuditChannel.DbOutbound)]);
await DrainAsync();
await using (var ctx = CreateContext())
{
Assert.Equal(2, await new AuditLogRepository(ctx).PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100));
}
await InsertAsync([NewEvent(month.AddDays(20))]); // written after the drain: unchained
var before = await PartitionRowsAsync(month);
Assert.Equal(11, before.Count);
var manifest = await ArchiveAsync(month);
Assert.Equal(AuditArchiveStatus.Archived, manifest.Status);
Assert.Equal(11, manifest.RowCount);
Assert.Equal(10, manifest.ChainedRowCount);
Assert.Equal(1, manifest.UnchainedRowCount);
Assert.Equal(2, manifest.TombstoneCount);
var chain = Assert.Single(manifest.Chains);
Assert.Equal("2026-02", chain.Partition);
Assert.Equal(12, chain.LastSeq);
// Row equality: the archive's data file holds exactly the partition's stored rows.
var archived = await ReadArchiveRowsAsync(manifest);
Assert.Equal(before.Select(Key).Order(), archived.Select(Key).Order());
await using (var ctx = CreateContext())
{
Assert.Equal(11, await Archive(ctx).PurgeArchivedPartitionAsync(manifest));
}
Assert.Empty(await PartitionRowsAsync(month));
var monthChain = await VerifyMonthAsync(month);
Assert.Equal(AuditChainStatus.Intact, monthChain.Status);
Assert.Equal(12, monthChain.PurgedThroughSeq);
var verification = await VerifyArchiveAsync(manifest.Name);
Assert.True(verification.Verified, verification.FileProblem);
Assert.Equal(AuditArchiveStatus.Purged, verification.ManifestStatus);
Assert.Equal(11, verification.RowsRead);
var v = Assert.Single(verification.Chains);
Assert.Equal(AuditChainStatus.Intact, v.Chain.Status);
Assert.Equal(10, v.Chain.RowsVerified);
Assert.Equal(2, v.Chain.TombstonesApplied);
Assert.Equal(1, v.Chain.UnchainedRows);
Assert.Equal(AuditArchiveAnchorStatus.Matches, v.Anchor);
await using (var ctx = CreateContext())
{
var listed = Assert.Single(await Archive(ctx).ListAsync(), m => m.Partition == "2026-02");
Assert.Equal(AuditArchiveStatus.Purged, listed.Status);
Assert.NotNull(listed.PurgedAtUtc);
}
}
/// <summary>
/// A row that lands in the partition after it was archived makes the purge refuse
/// (nothing dropped); a fresh archive then purges. The stale archive still verifies as a
/// complete, un-purged copy.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task RowArrivingAfterTheArchive_StopsThePurge()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var month = Month(2026, 3);
await InsertAsync(Enumerable.Range(1, 5).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
var stale = await ArchiveAsync(month);
await InsertAsync([NewEvent(month.AddDays(9))]);
await using (var ctx = CreateContext())
{
var ex = await Assert.ThrowsAsync<AuditArchiveStaleException>(() => Archive(ctx).PurgeArchivedPartitionAsync(stale));
Assert.Equal(AuditPartitionSwitchGuard.RowCountChangedError, ((SqlException)ex.InnerException!).Number);
}
Assert.Equal(6, (await PartitionRowsAsync(month)).Count);
Assert.Null((await VerifyMonthAsync(month)).PurgedThroughSeq);
var staleVerification = await VerifyArchiveAsync(stale.Name);
Assert.True(staleVerification.Verified);
Assert.Equal(AuditArchiveStatus.Archived, staleVerification.ManifestStatus);
Assert.Equal(AuditArchiveAnchorStatus.NotPurged, Assert.Single(staleVerification.Chains).Anchor);
// Positive control: a fresh archive holds the new row and is allowed to purge.
var fresh = await ArchiveAsync(month);
Assert.Equal(6, fresh.RowCount);
await using (var ctx = CreateContext())
{
Assert.Equal(6, await Archive(ctx).PurgeArchivedPartitionAsync(fresh));
}
Assert.Empty(await PartitionRowsAsync(month));
}
/// <summary>
/// A chaining pass between the archive and the purge (the row count is unchanged, the
/// chain head moved) also stops the purge: otherwise the anchor would cover links the
/// archive does not hold.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ChainAdvancingAfterTheArchive_StopsThePurge()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var month = Month(2026, 4);
await InsertAsync(Enumerable.Range(1, 4).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
await InsertAsync([NewEvent(month.AddDays(8))]); // unchained at archive time
var manifest = await ArchiveAsync(month);
Assert.Equal(1, manifest.UnchainedRowCount);
await DrainAsync(); // links the row: head 4 → 5, row count unchanged
await using (var ctx = CreateContext())
{
var ex = await Assert.ThrowsAsync<AuditArchiveStaleException>(() => Archive(ctx).PurgeArchivedPartitionAsync(manifest));
Assert.Equal(AuditPartitionSwitchGuard.ChainAdvancedError, ((SqlException)ex.InnerException!).Number);
}
Assert.Equal(5, (await PartitionRowsAsync(month)).Count);
}
/// <summary>
/// Tampering with a purged archive is caught three ways: a flipped byte (file hash), a
/// re-written file with an edited row and a re-hashed manifest (chain), and a forger who
/// also recomputes the whole chain (the purge anchor in the database no longer matches).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task TamperedArchive_FailsVerification()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var month = Month(2026, 5);
await InsertAsync(Enumerable.Range(1, 8).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
var manifest = await ArchiveAndPurgeAsync(month);
Assert.True((await VerifyArchiveAsync(manifest.Name)).Verified);
var dataPath = Path.Combine(_root.FullName, "2026", manifest.DataFile);
var manifestPath = Path.Combine(_root.FullName, "2026", manifest.Name + ".manifest.json");
var original = await File.ReadAllBytesAsync(dataPath);
var originalManifest = await File.ReadAllTextAsync(manifestPath);
// 1. A flipped byte.
var flipped = (byte[])original.Clone();
flipped[original.Length / 2] ^= 0xFF;
await File.WriteAllBytesAsync(dataPath, flipped);
var result = await VerifyArchiveAsync(manifest.Name);
Assert.False(result.Verified);
Assert.False(result.FilesIntact);
Assert.Contains("SHA-256", result.FileProblem);
// 2. An edited row in a re-written file, manifest hash/size updated to match.
var rows = await ReadArchiveRowsAsync(manifest, original);
var victim = rows[3];
rows[3] = victim with { Target = "forged" };
await RewriteAsync(rows, manifest, dataPath, manifestPath, lastHash: null);
result = await VerifyArchiveAsync(manifest.Name);
Assert.False(result.Verified);
Assert.True(result.FilesIntact);
var brk = Assert.Single(result.Chains).Chain.FirstBreak!;
Assert.Equal(AuditChainBreakKind.HashMismatch, brk.Kind);
Assert.Equal(victim.EventId, brk.EventId);
// 3. The forger recomputes every link and the manifest's chain end: the archive is
// self-consistent, but its end no longer matches the anchor the purge left.
var previous = AuditChainHasher.GenesisHash.ToArray();
for (var i = 0; i < rows.Count; i++)
{
var hash = AuditChainHasher.ComputeRowHash(previous, month, rows[i].ChainSeq!.Value, rows[i]);
rows[i] = rows[i] with { RowHash = hash };
previous = hash;
}
await RewriteAsync(rows, manifest, dataPath, manifestPath, lastHash: Convert.ToHexStringLower(previous));
result = await VerifyArchiveAsync(manifest.Name);
Assert.False(result.Verified);
var c = Assert.Single(result.Chains);
Assert.Equal(AuditChainStatus.Intact, c.Chain.Status);
Assert.Equal(AuditArchiveAnchorStatus.Mismatch, c.Anchor);
// Positive control: the untouched archive still verifies.
await File.WriteAllBytesAsync(dataPath, original);
await File.WriteAllTextAsync(manifestPath, originalManifest);
Assert.True((await VerifyArchiveAsync(manifest.Name)).Verified);
}
/// <summary>
/// Rows that arrive for a month after it was purged chain on from the anchor; archiving
/// and purging them again produces a second archive that resumes from the first one's end.
/// The first archive then reports its anchor as superseded, and both verify.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task LateRows_AreArchivedAgain_FromTheAnchor()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var month = Month(2026, 6);
await InsertAsync(Enumerable.Range(1, 3).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
var first = await ArchiveAndPurgeAsync(month);
await InsertAsync([NewEvent(month.AddDays(15)), NewEvent(month.AddDays(16))]);
await DrainAsync();
await Task.Delay(5); // distinct archive name (millisecond stamp)
var second = await ArchiveAndPurgeAsync(month);
var chain = Assert.Single(second.Chains);
Assert.Equal(3, chain.PriorPurgedThroughSeq);
Assert.Equal(5, chain.LastSeq);
var secondResult = await VerifyArchiveAsync(second.Name);
Assert.True(secondResult.Verified);
Assert.Equal(AuditArchiveAnchorStatus.Matches, Assert.Single(secondResult.Chains).Anchor);
Assert.Equal(2, Assert.Single(secondResult.Chains).Chain.RowsVerified);
var firstResult = await VerifyArchiveAsync(first.Name);
Assert.True(firstResult.Verified);
Assert.Equal(AuditArchiveAnchorStatus.Superseded, Assert.Single(firstResult.Chains).Anchor);
}
/// <summary>A name that is not an archive name (e.g. a path) is rejected before any file access.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task VerifyRejectsPathLikeNames()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
await using var ctx = CreateContext();
await Assert.ThrowsAsync<ArgumentException>(() => Archive(ctx).VerifyAsync("../../etc/passwd"));
await Assert.ThrowsAsync<FileNotFoundException>(() => Archive(ctx).VerifyAsync("auditlog-2026-01-20260101T000000000Z"));
}
// ── helpers ──────────────────────────────────────────────────────────────
private static DateTime Month(int year, int month) => new(year, month, 1, 0, 0, 0, DateTimeKind.Utc);
private AuditLogPartitionArchive Archive(ScadaBridgeDbContext ctx) => new(ctx, new AuditLogArchiveOptions
{
Enabled = true,
Directory = _root.FullName,
RowGroupSize = 1_000,
});
private async Task<AuditArchiveManifest> ArchiveAsync(DateTime month)
{
await using var ctx = CreateContext();
return await Archive(ctx).ArchivePartitionAsync(month);
}
private async Task<AuditArchiveManifest> ArchiveAndPurgeAsync(DateTime month)
{
var manifest = await ArchiveAsync(month);
await using var ctx = CreateContext();
await Archive(ctx).PurgeArchivedPartitionAsync(manifest);
return manifest;
}
private async Task<AuditArchiveVerificationResult> VerifyArchiveAsync(string name)
{
await using var ctx = CreateContext();
return await Archive(ctx).VerifyAsync(name);
}
private async Task<AuditChainVerificationResult> VerifyMonthAsync(DateTime month)
{
await using var ctx = CreateContext();
return await new AuditLogHashChain(ctx).VerifyMonthAsync(month);
}
private async Task<List<AuditChainRow>> ReadArchiveRowsAsync(AuditArchiveManifest manifest, byte[]? bytes = null)
{
bytes ??= await File.ReadAllBytesAsync(Path.Combine(_root.FullName, manifest.Partition[..4], manifest.DataFile));
var rows = new List<AuditChainRow>();
await foreach (var row in AuditArchiveParquet.ReadRowsAsync(new MemoryStream(bytes)))
{
rows.Add(row);
}
return rows;
}
private static async Task RewriteAsync(
List<AuditChainRow> rows, AuditArchiveManifest manifest, string dataPath, string manifestPath, string? lastHash)
{
var stream = new MemoryStream();
await using (var writer = await AuditArchiveParquet.CreateDataWriterAsync(stream, manifest.Partition, 1_000))
{
foreach (var row in rows)
{
await writer.AddAsync(row);
}
}
var bytes = stream.ToArray();
await File.WriteAllBytesAsync(dataPath, bytes);
var forged = manifest with
{
Status = AuditArchiveStatus.Purged,
DataSha256 = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(bytes)),
DataBytes = bytes.Length,
Chains = lastHash is null ? manifest.Chains : [manifest.Chains[0] with { LastHash = lastHash }],
};
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(forged, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
}
private async Task<List<AuditChainRow>> PartitionRowsAsync(DateTime month)
{
await using var conn = new SqlConnection(_fixture.ConnectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT {AuditLogHashChain.RowColumns}, ChainSeq, RowHash FROM dbo.AuditLog "
+ "WHERE OccurredAtUtc >= @s AND OccurredAtUtc < @e;";
cmd.Parameters.Add(new SqlParameter("@s", System.Data.SqlDbType.DateTime2) { Value = month });
cmd.Parameters.Add(new SqlParameter("@e", System.Data.SqlDbType.DateTime2) { Value = month.AddMonths(1) });
var rows = new List<AuditChainRow>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
rows.Add(AuditLogHashChain.ReadRow(reader, withLink: true));
}
return rows;
}
private static string Key(AuditChainRow r) => string.Join('|',
r.EventId, r.OccurredAtUtc.Ticks, r.Actor ?? "<null>", r.Action, r.Outcome, r.Category, r.Target ?? "<null>",
r.SourceNode ?? "<null>", r.CorrelationId?.ToString() ?? "<null>", r.DetailsJson ?? "<null>",
r.ChainSeq?.ToString() ?? "<null>", r.RowHash is null ? "<null>" : Convert.ToHexString(r.RowHash));
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 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: "archive-test",
target: "erp",
sourceNode: "central-a",
sourceSiteId: "archive-test",
correlationId: Guid.NewGuid());
}