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
@@ -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">