feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog

This commit is contained in:
Joseph Doherty
2026-05-21 17:04:39 -04:00
parent e4b37e2798
commit 0a8709e5c5
9 changed files with 1772 additions and 12 deletions

View File

@@ -33,6 +33,13 @@ public sealed record AuditEvent
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
/// run was spawned by another; null for top-level runs. Lets a spawned
/// execution point back at its spawner for cross-run correlation.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }

View File

@@ -12,8 +12,8 @@ namespace ScadaLink.Commons.Types.Audit;
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
/// dimensions constrain on equality when set.
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
/// </summary>
public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null,
@@ -24,5 +24,6 @@ public sealed record AuditLogQueryFilter(
string? Actor = null,
Guid? CorrelationId = null,
Guid? ExecutionId = null,
Guid? ParentExecutionId = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);

View File

@@ -93,6 +93,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[ExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Execution");
builder.HasIndex(e => e.ParentExecutionId)
.HasFilter("[ParentExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_ParentExecution");
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");

View File

@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>ParentExecutionId</c> correlation column to the centralized
/// <c>AuditLog</c> table (#23). <c>ParentExecutionId</c> carries the
/// <c>ExecutionId</c> of the execution that spawned this run, letting a
/// spawned execution point back at its spawner — a sibling to the universal
/// per-run <c>ExecutionId</c>.
///
/// The change is purely additive:
/// 1. <c>ParentExecutionId uniqueidentifier NULL</c> is added with no default,
/// so the operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does
/// NOT rewrite the monthly-partitioned <c>AuditLog</c> table, and
/// historical rows stay <c>NULL</c> (no backfill).
/// 2. <c>IX_AuditLog_ParentExecution</c> is created via raw SQL so it lands on
/// the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching
/// every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
/// preserves the partition-switch purge path (see
/// AuditLogRepository.SwitchOutPartitionAsync).
/// </summary>
public partial class AddAuditLogParentExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentExecutionId",
table: "AuditLog",
type: "uniqueidentifier",
nullable: true);
// Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_Execution (filtered, aligned).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
ON dbo.AuditLog (ParentExecutionId)
WHERE ParentExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_ParentExecution' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog;");
migrationBuilder.DropColumn(
name: "ParentExecutionId",
table: "AuditLog");
}
}
}

View File

@@ -96,6 +96,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<Guid?>("ParentExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("PayloadTruncated")
.HasColumnType("bit");
@@ -149,6 +152,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
b.HasIndex("ParentExecutionId")
.HasDatabaseName("IX_AuditLog_ParentExecution")
.HasFilter("[ParentExecutionId] IS NOT NULL");
b.HasIndex("SourceSiteId", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Site_Occurred");

View File

@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId},
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
@@ -162,6 +162,11 @@ VALUES
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.ParentExecutionId is { } parentExecutionId)
{
query = query.Where(e => e.ParentExecutionId == parentExecutionId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -268,10 +273,13 @@ VALUES
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
-- ExecutionId is last because it was added to the live AuditLog table by a later
-- ALTER TABLE ADD migration; the staging table must match the live table column
-- shape ordinal-for-ordinal or ALTER TABLE ... SWITCH PARTITION fails.
-- ExecutionId and ParentExecutionId are last (in this ordinal order)
-- because each was added to the live AuditLog table by a later
-- ALTER TABLE ADD migration; the staging table must match the live
-- table column shape ordinal-for-ordinal or
-- ALTER TABLE ... SWITCH PARTITION fails.
ExecutionId uniqueidentifier NULL,
ParentExecutionId uniqueidentifier NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];