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