Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
T
2026-09-09 22:56:36 -04:00

1442 lines
67 KiB
C#

using System.Data;
using System.Data.Common;
using System.Text;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
/// for the append-only contract; this class only adds notes on the data-access
/// strategy used by each method.
/// </summary>
public class AuditLogRepository : IAuditLogRepository
{
// SQL Server error numbers for duplicate-key violations on the
// partition-aligned clustered PK_AuditLog (EventId, OccurredAtUtc).
// 2601 is a unique-index violation; 2627 is a primary-key/unique-constraint
// violation. The IF NOT EXISTS … INSERT pattern has a check-then-act race
// window — two sessions can both pass the EXISTS check and then both attempt
// the INSERT — and the loser surfaces as one of these errors. Idempotency
// demands we swallow them.
private const int SqlErrorUniqueIndexViolation = 2601;
private const int SqlErrorPrimaryKeyViolation = 2627;
// Rows per set-based ingest statement. Ten bound parameters per row against
// SQL Server's 2,100-parameter ceiling leaves ample headroom at 100 rows
// (1,000 parameters) while still collapsing a typical telemetry packet into
// a single round trip. Larger chunks buy little — the win is round-trip
// elimination, not statement size — and would push plan-cache churn up
// (one cached plan per distinct row count).
private const int IngestChunkRows = 100;
// Ordinal-stable column list shared by the single-row and set-based inserts.
// The five persisted computed columns (Kind/Status/SourceSiteId/ExecutionId/
// ParentExecutionId) plus the non-persisted IngestedAtUtc are derived
// server-side from DetailsJson and must NEVER appear here — writing a
// computed column is an error.
private const string CanonicalColumnList =
"EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson";
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<AuditLogRepository> _logger;
/// <summary>Initializes a new instance of the AuditLogRepository class.</summary>
/// <param name="context">The database context.</param>
/// <param name="logger">Optional logger instance.</param>
public AuditLogRepository(ScadaBridgeDbContext context, ILogger<AuditLogRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<AuditLogRepository>.Instance;
}
/// <inheritdoc />
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
{
throw new ArgumentNullException(nameof(evt));
}
// Write the 10 canonical columns DIRECTLY — no Decompose.
// The five queryability columns (Kind/Status/SourceSiteId/ExecutionId/
// ParentExecutionId) plus IngestedAtUtc are PERSISTED computed columns on
// dbo.AuditLog; SQL Server derives them from DetailsJson at INSERT, so they
// are intentionally absent from this column list (writing a computed column
// is an error). The canonical OccurredAtUtc is UTC by construction; store a
// Kind=Utc DateTime so downstream UTC/local conversions are safe.
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
// Canonical Actor is a required non-null string; an empty Actor maps to a
// NULL column (legacy/central rows stored null for system/anon).
string? actor = string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor;
// Outcome / Category are varchar columns (Outcome via HasConversion<string>;
// Category carries the channel name). Bind as strings rather than relying on
// parameter type inference.
var outcome = evt.Outcome.ToString();
string? category = evt.Category;
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
VALUES
({evt.EventId}, {occurred}, {actor}, {evt.Action}, {outcome}, {category}, {evt.Target}, {evt.SourceNode}, {evt.CorrelationId}, {evt.DetailsJson});",
ct);
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
// Two concurrent sessions both passed the IF NOT EXISTS check and
// both attempted the INSERT — the loser raises 2601/2627 against
// the clustered PK. First-write-wins idempotency is already the
// documented contract for this method, so the race outcome is
// semantically a no-op. Swallow at Debug; other SqlExceptions
// bubble.
_logger.LogDebug(
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
ex.Number,
evt.EventId);
}
}
/// <inheritdoc />
public async Task<int> InsertManyIfNotExistsAsync(
IReadOnlyList<AuditEvent> events,
TimeSpan? commandTimeout = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(events);
if (events.Count == 0)
{
return 0;
}
// De-duplicate WITHIN the packet before the statement is built. The
// set-based INSERT … SELECT … WHERE NOT EXISTS only tests rows that are
// ALREADY committed, so two copies of one EventId inside a single VALUES
// constructor would both pass the anti-semi-join and collide on the
// clustered PK — losing the whole statement to a duplicate-key fault.
// First-write-wins matches the single-row contract exactly (a later copy
// of the same EventId is by definition the same immutable append-only
// row), so keeping the first occurrence is not merely convenient, it is
// the documented semantics.
var seen = new HashSet<Guid>(events.Count);
var distinct = new List<AuditEvent>(events.Count);
foreach (var evt in events)
{
if (evt is not null && seen.Add(evt.EventId))
{
distinct.Add(evt);
}
}
var inserted = 0;
for (var offset = 0; offset < distinct.Count; offset += IngestChunkRows)
{
var length = Math.Min(IngestChunkRows, distinct.Count - offset);
var chunk = distinct.GetRange(offset, length);
try
{
inserted += await InsertChunkAsync(chunk, commandTimeout, ct).ConfigureAwait(false);
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
// A CONCURRENT writer committed one of this chunk's EventIds
// between the anti-semi-join and the insert (the same
// check-then-act window the single-row path documents), and the
// whole set-based statement rolled back with it. Fall back to
// the per-row path so the rows that are genuinely new still land
// — the batch is a throughput optimisation, never a correctness
// dependency. Every row is retried, including the one that
// collided, because InsertIfNotExistsAsync swallows its own
// duplicate-key fault as a no-op.
_logger.LogDebug(
ex,
"Set-based audit ingest chunk of {Count} row(s) hit a duplicate-key violation (error {SqlErrorNumber}); falling back to per-row inserts.",
chunk.Count,
ex.Number);
foreach (var evt in chunk)
{
await InsertIfNotExistsAsync(evt, ct).ConfigureAwait(false);
}
}
}
return inserted;
}
/// <summary>
/// Executes one set-based idempotent insert: a VALUES table constructor
/// anti-semi-joined against the committed rows, so a whole telemetry packet
/// costs ONE round trip instead of one <c>IF NOT EXISTS … INSERT</c> per row.
/// </summary>
/// <remarks>
/// Raw ADO.NET (rather than <c>ExecuteSqlInterpolated</c>) because the
/// statement's parameter count varies with the chunk size and every parameter
/// needs an explicit <see cref="SqlDbType"/>: the VALUES constructor's column
/// types are inferred from the first row's parameters, so leaving a null
/// <c>Target</c>/<c>SourceNode</c> untyped would give the derived column the
/// wrong type and defeat the seek on the anti-semi-join. The command enlists
/// in the DbContext's ambient transaction when one is open — the cached
/// telemetry dual-write runs the audit insert and the SiteCalls upsert inside
/// a single transaction and both must commit or roll back together.
/// </remarks>
private async Task<int> InsertChunkAsync(
IReadOnlyList<AuditEvent> chunk, TimeSpan? commandTimeout, CancellationToken ct)
{
var sql = new StringBuilder(256 + (chunk.Count * 64));
sql.Append("INSERT INTO dbo.AuditLog (").Append(CanonicalColumnList).Append(")\n");
sql.Append("SELECT v.EventId, v.OccurredAtUtc, v.Actor, v.Action, v.Outcome, v.Category, ");
sql.Append("v.Target, v.SourceNode, v.CorrelationId, v.DetailsJson\n");
sql.Append("FROM (VALUES\n");
for (var i = 0; i < chunk.Count; i++)
{
if (i > 0)
{
sql.Append(",\n");
}
sql.Append(" (@e").Append(i)
.Append(",@t").Append(i)
.Append(",@a").Append(i)
.Append(",@n").Append(i)
.Append(",@o").Append(i)
.Append(",@c").Append(i)
.Append(",@g").Append(i)
.Append(",@s").Append(i)
.Append(",@r").Append(i)
.Append(",@d").Append(i)
.Append(')');
}
sql.Append("\n) AS v (").Append(CanonicalColumnList).Append(")\n");
sql.Append("WHERE NOT EXISTS (SELECT 1 FROM dbo.AuditLog x WHERE x.EventId = v.EventId);");
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql.ToString();
cmd.Transaction = _context.Database.CurrentTransaction?.GetDbTransaction();
if (commandTimeout is { } timeout)
{
cmd.CommandTimeout = (int)timeout.TotalSeconds;
}
for (var i = 0; i < chunk.Count; i++)
{
var evt = chunk[i];
// Same canonical projection as the single-row path: UTC-kind
// OccurredAtUtc, empty Actor collapses to NULL, Outcome/Category
// bound as their varchar storage form.
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
object actor = string.IsNullOrEmpty(evt.Actor) ? DBNull.Value : evt.Actor;
// NO explicit Size on the string parameters — size 0 means "bind
// the value's own length" (see AddParameter). Declaring the
// COLUMN width here (Actor/Target 256, Action 64, Outcome 16,
// Category 32, SourceNode 64) made SqlClient TRUNCATE an
// over-long value client-side and commit the mutilated row, while
// the single-row path (ExecuteSqlInterpolated, no Size) and the
// reconciliation path sent the same value in full and let the
// server reject it with error 2628. An append-only audit store
// must never silently store a shortened row — there is no
// truncation flag on the record to say it happened — so the batch
// path now defers the length check to the server exactly like
// every other path: reject everywhere, truncate nowhere.
// The explicit SqlDbType stays (it is what fixes the VALUES
// constructor's derived column types, and DateTime2 precision).
AddParameter(cmd, "@e" + i, SqlDbType.UniqueIdentifier, size: 0, evt.EventId);
AddParameter(cmd, "@t" + i, SqlDbType.DateTime2, size: 0, occurred);
AddParameter(cmd, "@a" + i, SqlDbType.NVarChar, size: 0, actor);
AddParameter(cmd, "@n" + i, SqlDbType.VarChar, size: 0, evt.Action);
AddParameter(cmd, "@o" + i, SqlDbType.VarChar, size: 0, evt.Outcome.ToString());
AddParameter(cmd, "@c" + i, SqlDbType.VarChar, size: 0, evt.Category);
AddParameter(cmd, "@g" + i, SqlDbType.NVarChar, size: 0, evt.Target);
AddParameter(cmd, "@s" + i, SqlDbType.VarChar, size: 0, evt.SourceNode);
AddParameter(cmd, "@r" + i, SqlDbType.UniqueIdentifier, size: 0, evt.CorrelationId);
AddParameter(cmd, "@d" + i, SqlDbType.NVarChar, size: -1, evt.DetailsJson);
}
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Binds one explicitly-typed parameter. A null CLR value binds as
/// <see cref="DBNull"/> while KEEPING its declared <see cref="SqlDbType"/>,
/// which is what makes the VALUES constructor's derived column types stable
/// regardless of which rows happen to carry nulls.
/// </summary>
/// <remarks>
/// <paramref name="size"/> semantics: <c>0</c> leaves <see cref="SqlParameter.Size"/>
/// unset so SqlClient sizes the parameter from the VALUE — the only safe
/// choice for the variable-length audit columns, because an explicit Size
/// SMALLER than the value silently truncates it at bind time instead of
/// letting the server raise 2628. <c>-1</c> is the explicit MAX marker
/// (nvarchar(max) DetailsJson). Never pass a column width here.
/// </remarks>
private static void AddParameter(
System.Data.Common.DbCommand cmd, string name, SqlDbType type, int size, object? value)
{
var p = (SqlParameter)cmd.CreateParameter();
p.ParameterName = name;
p.SqlDbType = type;
if (size != 0)
{
p.Size = size;
}
p.Value = value ?? DBNull.Value;
cmd.Parameters.Add(p);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
{
if (filter is null)
{
throw new ArgumentNullException(nameof(filter));
}
if (paging is null)
{
throw new ArgumentNullException(nameof(paging));
}
// The filter predicates bind to the canonical columns and the
// persisted computed columns directly — Channel→Category, Kind/Status/
// SourceSiteId/ExecutionId/ParentExecutionId are computed columns. The
// materialized rows are projected to the canonical record by reading the 10
// canonical columns (no 24-column Recompose).
var query = _context.Set<AuditLogRow>().AsNoTracking();
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
if (filter.Channels is { Count: > 0 } channels)
{
query = query.Where(e => channels.Contains(e.Channel));
}
if (filter.Kinds is { Count: > 0 } kinds)
{
query = query.Where(e => kinds.Contains(e.Kind));
}
if (filter.Statuses is { Count: > 0 } statuses)
{
query = query.Where(e => statuses.Contains(e.Status));
}
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
// SourceNode filter mirrors SourceSiteIds: a non-empty list translates to
// SQL IN (…); NULL SourceNode rows are excluded when the filter is set.
if (filter.SourceNodes is { Count: > 0 } sourceNodes)
{
query = query.Where(e => e.SourceNode != null && sourceNodes.Contains(e.SourceNode));
}
if (!string.IsNullOrEmpty(filter.Target))
{
var target = filter.Target;
query = query.Where(e => e.Target == target);
}
if (!string.IsNullOrEmpty(filter.Actor))
{
var actor = filter.Actor;
query = query.Where(e => e.Actor == actor);
}
if (filter.CorrelationId is { } correlationId)
{
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.ExecutionId is { } executionId)
{
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);
}
if (filter.ToUtc is { } toUtc)
{
query = query.Where(e => e.OccurredAtUtc <= toUtc);
}
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
{
query = query.Where(e =>
e.OccurredAtUtc < afterOccurred
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
var rows = await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
return rows.Select(RowToCanonical).ToList();
}
/// <summary>
/// Build the canonical <see cref="AuditEvent"/> DIRECTLY from the
/// 10 canonical columns of a materialized <see cref="AuditLogRow"/> read back from
/// <c>dbo.AuditLog</c> — no 24-column <c>Recompose</c>, because the table now holds
/// the canonical shape (every ScadaBridge domain field already lives in
/// <c>DetailsJson</c>). The persisted computed columns are read helpers only and
/// are not part of the canonical record. <see cref="AuditLogRow.Channel"/> is the
/// canonical <c>Category</c> column (Category = channel name for ScadaBridge).
/// </summary>
private static AuditEvent RowToCanonical(AuditLogRow row)
=> new()
{
EventId = row.EventId,
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)),
Actor = row.Actor ?? string.Empty,
Action = row.Action,
Outcome = row.Outcome,
Category = row.Channel.ToString(),
Target = row.Target,
SourceNode = row.SourceNode,
CorrelationId = row.CorrelationId,
DetailsJson = row.DetailsJson,
};
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
{
// The switch batch below runs via
// ExecuteSqlRaw with NO EF user-transaction — it carries its own server-side
// BEGIN TRANSACTION / TRY-CATCH / ROLLBACK — so the DbContext's retrying
// execution strategy (EnableRetryOnFailure) MAY auto-replay the whole batch on
// a transient fault. That replay is safe: every step is IF-EXISTS / IF-NOT-EXISTS
// guarded and the staging table is GUID-suffixed, so a re-run is idempotent.
//
// ALIGNED UNIQUENESS (WP2.2): there is no longer an index drop/rebuild around
// the SWITCH. EventId uniqueness is now enforced solely by the clustered
// PK_AuditLog (EventId, OccurredAtUtc), which is partition-aligned on
// ps_AuditLog_Month(OccurredAtUtc) — so ALTER TABLE … SWITCH PARTITION has no
// non-aligned index to object to. The former dance dropped UX_AuditLog_EventId,
// switched, then rebuilt it OFFLINE inside the same transaction: a whole-table
// unique-index build blocking every writer for the duration of the purge, and a
// window in which the idempotency-supporting index did not exist at all. Both
// are gone. See migration AlignAuditLogEventIdUniqueness for the reasoning.
//
// Maintenance timeout in whole seconds (ADO.NET CommandTimeout unit). Null leaves the
// provider default in place. See AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes /
// arch-review 04 S2 for why the ~30s default is unsafe for the switch-out dance.
var commandTimeoutSeconds = commandTimeout is { } ct2 ? (int)ct2.TotalSeconds : (int?)null;
// GUID-suffixed staging name: prevents collision with any concurrent
// purge attempt and avoids polluting the AuditLog object namespace with
// a predictable identifier.
var stagingTableName = $"AuditLog_Staging_{Guid.NewGuid():N}";
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
// unambiguously and the value is round-trip-safe across SET DATEFORMAT
// settings. Use datetime2(7) precision (.fffffff) so a future
// non-midnight or sub-second boundary doesn't silently round to the
// wrong partition (today the migration only seeds at T00:00:00 exactly,
// but the format string is on the boundary value's own contract — match
// it to the column precision rather than to the current seed pattern).
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
// Two-statement batch: the first SELECT samples the per-partition row
// count BEFORE the dance so we can report it back to the purge actor;
// the second batch performs the drop-and-rebuild. We use OUTPUT-style
// variables wired through @@ROWCOUNT after the SWITCH is not viable
// because SWITCH is a metadata-only operation that doesn't move rows in
// a way @@ROWCOUNT can observe.
var sampleSql = $@"
SELECT COUNT_BIG(*) FROM dbo.AuditLog
WHERE $PARTITION.pf_AuditLog_Month(OccurredAtUtc) =
$partition.pf_AuditLog_Month('{monthBoundaryStr}');";
var sql = $@"
BEGIN TRY
BEGIN TRANSACTION;
-- 1. Defensive cleanup for databases created before
-- AlignAuditLogEventIdUniqueness: the migration drops the
-- non-aligned UX_AuditLog_EventId, but a database restored from an
-- older backup could still carry it and SWITCH refuses to run while
-- a non-aligned unique index exists. Dropping it here is idempotent
-- and permanent — the aligned clustered PK is the only uniqueness
-- enforcement the ingest path needs, so there is nothing to rebuild.
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
-- 2. Staging table on [PRIMARY] (non-partitioned) with column shapes
-- byte-identical to the dbo.AuditLog — INCLUDING the persisted
-- computed columns, whose definitions must match EXACTLY (same
-- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION
-- rejects the operation with msg 4904/4948. The ordinal order also
-- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration):
-- 10 canonical columns first, then the 6 computed columns.
CREATE TABLE dbo.[{stagingTableName}] (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
Actor nvarchar(256) NULL,
Action varchar(64) NOT NULL,
Outcome varchar(16) NOT NULL,
Category varchar(32) NOT NULL,
Target nvarchar(256) NULL,
SourceNode varchar(64) NULL,
CorrelationId uniqueidentifier NULL,
DetailsJson nvarchar(max) NULL,
Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED,
Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED,
SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED,
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];
-- 3. Switch the partition out. $partition.pf_AuditLog_Month returns
-- the partition number that contains the supplied boundary value;
-- SWITCH PARTITION N moves that partition's pages to the staging
-- table (metadata-only, no row copying).
DECLARE @partitionNumber int = $partition.pf_AuditLog_Month('{monthBoundaryStr}');
DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];';
EXEC sp_executesql @sql;
-- 4. Drop staging — the rows are discarded here. This is the purge.
DROP TABLE dbo.[{stagingTableName}];
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
-- Best-effort staging cleanup. The staging table from step 2 may or
-- may not survive the rollback depending on the failure point. Guard
-- the DROP so a missing staging table doesn't mask the original error.
-- Nothing else needs repairing: uniqueness lives on the clustered PK,
-- which the switch never touches, so a failed purge can no longer
-- leave the live table without its idempotency enforcement.
IF OBJECT_ID('dbo.[{stagingTableName}]', 'U') IS NOT NULL DROP TABLE dbo.[{stagingTableName}];
-- Surface the original error to the caller — the purge actor logs
-- and continues with the next boundary.
THROW;
END CATCH;";
// Sample the row count before the switch. The sample is best-effort
// (no transaction wrapping the sample-then-switch pair) because the
// central singleton is the only writer to this RPC and a daily-purge
// tick doesn't compete with concurrent SwitchOut callers. A
// concurrent INSERT racing the sample under-reports by at most a
// few rows, which is acceptable for an "approximate" purged-row
// count surfaced via AuditLogPurgedEvent.
long rowsDeleted = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using (var sampleCmd = conn.CreateCommand())
{
sampleCmd.CommandText = sampleSql;
if (commandTimeoutSeconds is { } sampleTimeout)
{
sampleCmd.CommandTimeout = sampleTimeout;
}
var sampleResult = await sampleCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
if (sampleResult is not null && sampleResult is not DBNull)
{
rowsDeleted = Convert.ToInt64(sampleResult);
}
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
// Apply the maintenance timeout to the drop-and-rebuild dance. The context is scoped per
// purge tick (see AuditLogPurgeActor.OnTickAsync's CreateAsyncScope), so a restore is not
// strictly required — but we restore the previous value in a finally for hygiene in case a
// future caller reuses the context for other work after the switch-out.
var previousTimeout = _context.Database.GetCommandTimeout();
if (commandTimeout is { } maintenanceTimeout)
{
_context.Database.SetCommandTimeout(maintenanceTimeout);
}
try
{
await _context.Database.ExecuteSqlRawAsync(sql, ct);
}
finally
{
if (commandTimeout is not null)
{
_context.Database.SetCommandTimeout(previousTimeout);
}
}
return rowsDeleted;
}
/// <inheritdoc />
public async Task<long> PurgeChannelOlderThanAsync(
string channel,
DateTime threshold,
int batchSize,
TimeSpan? commandTimeout = null,
CancellationToken ct = default, int maxRows = 50_000)
{
if (string.IsNullOrWhiteSpace(channel))
{
throw new ArgumentException("Channel must be a non-empty channel name.", nameof(channel));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
}
if (maxRows <= 0)
throw new ArgumentOutOfRangeException(nameof(maxRows), maxRows, "Row budget must be > 0.");
var thresholdUtc = DateTime.SpecifyKind(threshold.ToUniversalTime(), DateTimeKind.Utc);
// Maintenance timeout in whole seconds; null leaves the provider default (arch-review 04 S2).
var commandTimeoutSeconds = commandTimeout is { } cto ? (int)cto.TotalSeconds : (int?)null;
// Per-channel retention override purge. This is the ONLY DELETE
// against dbo.AuditLog in the codebase and it runs on the purge/maintenance
// path, NOT the append-only writer role (which has INSERT + SELECT only — see
// the DENY UPDATE/DENY DELETE grants in CollapseAuditLogToCanonical). The
// AuditLog append-only CI guard (AuditLogAppendOnlyGuardTests) is intentionally
// widened to allow ONLY the single marked DELETE below; any other UPDATE/DELETE
// targeting AuditLog still trips the guard.
//
// Bounded + idempotent: DELETE TOP (@batch) caps the log/lock footprint per
// statement; the loop stops at its row budget or an empty batch, so re-running
// after a crash mid-loop simply resumes. Category is the canonical
// channel-name column (e.g. 'ApiOutbound'); Action holds "{channel}.{kind}" so
// it is NOT the right column to match a bare channel name against.
//
// The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
// recognizes; any other UPDATE/DELETE targeting AuditLog still trips the guard.
const string deleteBatchSql =
"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"; // AUDIT-PURGE-ALLOWED: per-channel retention override, maintenance path
long totalDeleted = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
while (totalDeleted < maxRows)
{
ct.ThrowIfCancellationRequested();
await using var cmd = conn.CreateCommand();
cmd.CommandText = deleteBatchSql;
if (commandTimeoutSeconds is { } batchTimeout)
{
cmd.CommandTimeout = batchTimeout;
}
var pBatch = cmd.CreateParameter();
pBatch.ParameterName = "@batch";
pBatch.Value = (int)Math.Min(batchSize, maxRows - totalDeleted);
cmd.Parameters.Add(pBatch);
var pChannel = cmd.CreateParameter();
pChannel.ParameterName = "@channel";
pChannel.Value = channel;
cmd.Parameters.Add(pChannel);
var pThreshold = cmd.CreateParameter();
pThreshold.ParameterName = "@threshold";
pThreshold.Value = thresholdUtc;
cmd.Parameters.Add(pThreshold);
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
if (rows <= 0)
{
break;
}
totalDeleted += rows;
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return totalDeleted;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default)
{
var thresholdUtc = threshold.ToUniversalTime();
var thresholdStr = thresholdUtc.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
// Per-partition MAX over the live table. We materialise the boundary
// list first (24 rows) then LEFT JOIN to the MAX aggregate so empty
// partitions surface as NULL and get filtered out by the WHERE clause.
var sql = $@"
WITH Boundaries AS (
SELECT CAST(rv.value AS datetime2(7)) AS BoundaryValue,
rv.boundary_id AS BoundaryId
FROM sys.partition_range_values rv
INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id
WHERE pf.name = 'pf_AuditLog_Month'
)
SELECT b.BoundaryValue
FROM Boundaries b
CROSS APPLY (
SELECT MAX(a.OccurredAtUtc) AS MaxOccurredAt
FROM dbo.AuditLog a
WHERE $PARTITION.pf_AuditLog_Month(a.OccurredAtUtc) = b.BoundaryId + 1
) x
WHERE x.MaxOccurredAt IS NOT NULL
AND x.MaxOccurredAt < CAST('{thresholdStr}' AS datetime2(7))
ORDER BY b.BoundaryValue;";
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
var results = new List<DateTime>();
try
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
// SQL Server's datetime2 surfaces as DateTimeKind.Unspecified
// through ADO.NET (the column type carries no offset/kind).
// Boundary values are stored in UTC, so re-tag the kind here —
// matches the explicit defence in
// AuditLogPartitionMaintenance.GetMaxBoundaryAsync and prevents
// downstream .ToLocalTime()/.ToUniversalTime() conversions
// from silently treating the value as local time.
results.Add(DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return results;
}
/// <inheritdoc />
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default)
{
var anchorUtc = (nowUtc ?? DateTime.UtcNow).ToUniversalTime();
var thresholdUtc = anchorUtc - window;
// ExecuteSqlInterpolated parameterises every interpolation — the enum
// discriminators are passed as varchar parameters that match the
// varchar(32) Status column (HasConversion<string>()).
var failedStr = nameof(Commons.Types.Enums.AuditStatus.Failed);
var parkedStr = nameof(Commons.Types.Enums.AuditStatus.Parked);
var discardedStr = nameof(Commons.Types.Enums.AuditStatus.Discarded);
long total = 0;
long errors = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using var cmd = conn.CreateCommand();
// Named parameters keep the prepared statement cache stable across
// calls — only the values change. COUNT_BIG returns a bigint so
// we read into long even when the running total fits in int.
cmd.CommandText = @"
SELECT
COUNT_BIG(*) AS Total,
SUM(CASE WHEN Status IN (@failed, @parked, @discarded) THEN 1 ELSE 0 END) AS Errors
FROM dbo.AuditLog
WHERE OccurredAtUtc >= @threshold
AND OccurredAtUtc <= @anchor;";
var pThreshold = cmd.CreateParameter();
pThreshold.ParameterName = "@threshold";
pThreshold.Value = thresholdUtc;
cmd.Parameters.Add(pThreshold);
var pAnchor = cmd.CreateParameter();
pAnchor.ParameterName = "@anchor";
pAnchor.Value = anchorUtc;
cmd.Parameters.Add(pAnchor);
var pFailed = cmd.CreateParameter();
pFailed.ParameterName = "@failed";
pFailed.Value = failedStr;
cmd.Parameters.Add(pFailed);
var pParked = cmd.CreateParameter();
pParked.ParameterName = "@parked";
pParked.Value = parkedStr;
cmd.Parameters.Add(pParked);
var pDiscarded = cmd.CreateParameter();
pDiscarded.ParameterName = "@discarded";
pDiscarded.Value = discardedStr;
cmd.Parameters.Add(pDiscarded);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
// SUM over an empty set is NULL; COUNT_BIG over an empty set is 0.
total = reader.IsDBNull(0) ? 0L : reader.GetInt64(0);
errors = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1));
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return new AuditLogKpiSnapshot(
TotalEventsLastHour: total,
ErrorEventsLastHour: errors,
BacklogTotal: 0L,
AsOfUtc: anchorUtc);
}
// Hard ceiling on chain depth for both the upward walk and the downward
// recursive CTE. The ParentExecutionId graph is a tree (acyclic by
// construction — each execution is minted fresh, its parent always
// pre-exists), so this is purely a guard against corrupt/pathological data:
// a cycle must surface as a bounded error rather than hang the server.
// Chains are shallow (1-2 levels typical) so the guard is never reached in
// practice.
private const int ExecutionChainMaxDepth = 32;
// Execution trees span minutes, not years — an inbound request and every script it
// spawns all fire inside one operational burst. Anchoring the Edges CTE scan to a
// narrow window around the root's first event lets SQL Server eliminate every
// AuditLog partition outside it, turning a full-table DISTINCT scan into a seek on
// IX_AuditLog_Execution over one or two months (arch-review 04, P2). The slack absorbs
// minor clock skew before the root; the max span is the documented traversal bound —
// a genuine descendant stamped beyond it is excluded by design.
private static readonly TimeSpan ExecutionTreeWindowSlack = TimeSpan.FromHours(1);
private static readonly TimeSpan ExecutionTreeMaxSpan = TimeSpan.FromDays(7);
/// <summary>
/// The anchor of one execution chain: the root execution id reached by the upward
/// <c>ParentExecutionId</c> walk, plus that root's first event timestamp (null when the
/// root is a row-less stub). Produced by <see cref="ResolveChainAnchorAsync"/> and
/// consumed by <see cref="ChainCtePrefix"/> / <see cref="AddChainParameters"/>.
/// </summary>
private sealed record ChainAnchor(Guid RootExecutionId, DateTime? RootFirstOccurred)
{
/// <summary>
/// The optional window predicate for the Edges CTE. When the root has rows the scan is
/// confined to the window (partition elimination); otherwise it stays unbounded.
/// </summary>
public string EdgesWindowPredicate => RootFirstOccurred is null
? string.Empty
: "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
}
/// <summary>
/// Phase 1 of the execution-chain walk, shared by <see cref="GetExecutionTreeAsync"/> and
/// <see cref="GetTraceEventsAsync"/> (arch-review F9): climb to the chain root, then read the
/// root's first event timestamp so the down-walk can be bounded to a root-anchored window.
/// The two walkers must stay in step on window slack, depth cap and the DISTINCT-edges trick,
/// so there is exactly one copy of each.
/// </summary>
private static string ScopedAuditSource(IReadOnlyList<string>? sites) => sites is null
? "dbo.AuditLog" : "(SELECT * FROM dbo.AuditLog WHERE SourceSiteId IS NULL OR SourceSiteId IN (SELECT [value] FROM OPENJSON(@scopeSites)))";
private static void AddSiteScopeParameter(DbCommand command, IReadOnlyList<string>? sites)
{
if (sites is null) return;
var parameter = command.CreateParameter(); parameter.ParameterName = "@scopeSites";
parameter.Value = System.Text.Json.JsonSerializer.Serialize(sites); command.Parameters.Add(parameter);
}
private static async Task<ChainAnchor> ResolveChainAnchorAsync(
DbConnection conn,
Guid executionId,
CancellationToken ct, IReadOnlyList<string>? permittedSiteIds = null)
{
// --- Phase 1: walk up to the root ---------------------------------
// Climb ParentExecutionId until a node has no parent (root) or the
// parent execution has no rows of its own (purged/stub — the climb
// cannot continue past a row-less node). The depth cap guards
// against a cycle in corrupt data; a tree never reaches it.
var rootExecutionId = executionId;
for (var depth = 0; depth < ExecutionChainMaxDepth; depth++)
{
Guid? parent;
await using (var upCmd = conn.CreateCommand())
{
upCmd.CommandText =
$"SELECT TOP 1 ParentExecutionId FROM {ScopedAuditSource(permittedSiteIds)} source " +
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
var pCur = upCmd.CreateParameter();
pCur.ParameterName = "@cur";
pCur.Value = rootExecutionId;
upCmd.Parameters.Add(pCur);
AddSiteScopeParameter(upCmd, permittedSiteIds);
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
parent = result is null or DBNull ? null : (Guid)result;
}
if (parent is null)
{
// No parent row for the current node — it is the root (or a
// row-less stub at the top of what survives). Stop climbing.
break;
}
rootExecutionId = parent.Value;
}
// --- Between phases: bound the down-walk to a root-anchored window ---
// The root's first event timestamp anchors a [rootFirst - slack, rootFirst + maxSpan)
// window used to prune the Edges CTE scan to a handful of partitions. A row-less stub
// root (purged/no-action parent) has no MIN — leave the scan unbounded in that
// degenerate case (correctness over speed).
DateTime? rootFirstOccurred;
await using (var winCmd = conn.CreateCommand())
{
winCmd.CommandText =
$"SELECT MIN(OccurredAtUtc) FROM {ScopedAuditSource(permittedSiteIds)} source WHERE ExecutionId = @root;";
var pWinRoot = winCmd.CreateParameter();
pWinRoot.ParameterName = "@root";
pWinRoot.Value = rootExecutionId;
winCmd.Parameters.Add(pWinRoot);
AddSiteScopeParameter(winCmd, permittedSiteIds);
var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
}
return new ChainAnchor(rootExecutionId, rootFirstOccurred);
}
/// <summary>
/// Phase 2's Edges/Chain CTE prefix — identical in both walkers (arch-review F9). Callers
/// append their own projection: <see cref="GetExecutionTreeAsync"/> the <c>ChainIds</c> CTE
/// plus the per-execution aggregate, <see cref="GetTraceEventsAsync"/> the bare id union.
/// Both must terminate with <c>OPTION (MAXRECURSION …)</c>.
/// </summary>
/// <remarks>
/// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
/// edge set distilled from AuditLog. Recursing over edges
/// instead of raw rows means an execution with N audit rows
/// contributes ONE recursion path, not N — MAXRECURSION
/// bounds depth, not per-level width, so the raw-row form
/// could fan out badly. One edge per execution because all
/// rows of an execution share a single ParentExecutionId
/// (see the MIN(...) note on the tree walker's projection).
/// Chain : seeded at the root edge, recursively joins each edge whose
/// ParentExecutionId is an ExecutionId already in the chain.
/// Each edge carries its own ParentExecutionId, so the chain
/// of edges already surfaces every execution id in the tree
/// — including a row-less stub parent, which appears as the
/// ParentExecutionId of its child's edge. No separate
/// union-back CTE is needed.
/// </remarks>
private static string ChainCtePrefix(string edgesWindowPredicate, IReadOnlyList<string>? permittedSiteIds = null) => $@"
WITH Edges AS (
SELECT DISTINCT ExecutionId, ParentExecutionId
FROM {ScopedAuditSource(permittedSiteIds)} scoped
WHERE ExecutionId IS NOT NULL
{edgesWindowPredicate}
),
Chain AS (
-- Anchor: the root execution id, seeded as a literal so
-- it is present even when the root is a row-less stub
-- (a purged/no-action parent owns no edge of its own).
-- The root is parentless by construction — the upward
-- walk stopped there — so its ParentExecutionId is NULL.
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
CAST(NULL AS uniqueidentifier) AS ParentExecutionId
UNION ALL
SELECT e.ExecutionId, e.ParentExecutionId
FROM Edges e
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
)";
/// <summary>
/// Binds the parameters the <see cref="ChainCtePrefix"/> statement needs: <c>@root</c> always,
/// and the window bounds only when the anchor carries a root timestamp (matching
/// <see cref="ChainAnchor.EdgesWindowPredicate"/>).
/// </summary>
private static void AddChainParameters(DbCommand cmd, ChainAnchor anchor)
{
var pRoot = cmd.CreateParameter();
pRoot.ParameterName = "@root";
pRoot.Value = anchor.RootExecutionId;
cmd.Parameters.Add(pRoot);
if (anchor.RootFirstOccurred is { } rootFirst)
{
var pWinStart = cmd.CreateParameter();
pWinStart.ParameterName = "@winStart";
pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
cmd.Parameters.Add(pWinStart);
var pWinEnd = cmd.CreateParameter();
pWinEnd.ParameterName = "@winEnd";
pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
cmd.Parameters.Add(pWinEnd);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
{
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
// --- Phase 1 + window: shared with GetTraceEventsAsync (arch-review F9) ---
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct, permittedSiteIds).ConfigureAwait(false);
// --- Phase 2: walk down from the root via a recursive CTE ---------
// The Edges/Chain prefix is ChainCtePrefix (see its remarks for the
// DISTINCT-edges trick); this walker appends:
// ChainIds: collect every distinct execution id reachable from the
// chain (each edge's ExecutionId plus its non-null
// ParentExecutionId), LEFT JOIN back to AuditLog and
// GROUP BY so a stub parent — which owns no edge of its own
// because it emitted no rows — still surfaces as a node with
// RowCount 0 and NULL aggregates.
var nodes = new List<ExecutionTreeNode>();
await using (var downCmd = conn.CreateCommand())
{
downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate, permittedSiteIds) + $@",
ChainIds AS (
SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain
WHERE ParentExecutionId IS NOT NULL
)
-- ExecutionId / ParentExecutionId / SourceSiteId
-- are persisted computed columns (same names); Channel is now the
-- canonical Category column (Category = channel name, so the
-- Channels aggregate still yields channel names); SourceInstanceId
-- is no longer a column — read it from DetailsJson via JSON_VALUE.
-- ParentExecutionId / SourceSiteId / SourceInstanceId are derived
-- via MIN: every audit row of one execution carries the SAME value
-- (stamped once per script run / inbound request) — MIN simply
-- picks that one shared value, not collapsing a genuine
-- disagreement across rows.
SELECT
ids.ExecutionId AS [ExecutionId],
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Category, ',')
FROM (SELECT DISTINCT a2.Category FROM {ScopedAuditSource(permittedSiteIds)} a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM {ScopedAuditSource(permittedSiteIds)} a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(JSON_VALUE(a.DetailsJson,'$.sourceInstanceId')) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
LEFT JOIN {ScopedAuditSource(permittedSiteIds)} a ON a.ExecutionId = ids.ExecutionId
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
AddChainParameters(downCmd, anchor);
AddSiteScopeParameter(downCmd, permittedSiteIds);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var nodeExecutionId = reader.GetGuid(0);
Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1);
var rowCount = reader.GetInt32(2);
var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3));
var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4));
var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5);
var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6);
DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7);
DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8);
nodes.Add(new ExecutionTreeNode(
ExecutionId: nodeExecutionId,
ParentExecutionId: parentExecutionId,
RowCount: rowCount,
Channels: channels,
Statuses: statuses,
SourceSiteId: sourceSiteId,
SourceInstanceId: sourceInstanceId,
FirstOccurredAtUtc: firstOccurred,
LastOccurredAtUtc: lastOccurred));
}
}
return nodes;
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(
Guid executionId,
CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
{
// Flow Debugger Replay (design 2026-09-05 §7.1): same walk as GetExecutionTreeAsync
// — up to the root, then the Edges/Chain CTE down — but projecting the chain's
// execution ids, then the rows themselves filtered to the trace + outbound channels
// + the inbound row. Two statements keep the row read on EF's canonical mapper
// (RowToCanonical) instead of a second hand-mapped reader.
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
var chainIds = new List<Guid>();
var rootId = executionId;
var chainTruncated = false;
try
{
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct, permittedSiteIds).ConfigureAwait(false);
rootId = anchor.RootExecutionId;
await using (var idsCmd = conn.CreateCommand())
{
idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate, permittedSiteIds) + $@"
SELECT TOP (2049) ExecutionId FROM (SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL) ids
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
AddChainParameters(idsCmd, anchor);
AddSiteScopeParameter(idsCmd, permittedSiteIds);
await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
if (!reader.IsDBNull(0)) { if (chainIds.Count == 2048) { chainTruncated = true; break; } chainIds.Add(reader.GetGuid(0)); }
}
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
if (chainIds.Count == 0)
{
return Array.Empty<AuditEvent>();
}
var query = _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(r => r.ExecutionId != null && chainIds.Contains(r.ExecutionId.Value))
.Where(r => permittedSiteIds == null || r.SourceSiteId == null || permittedSiteIds.Contains(r.SourceSiteId))
// Outbound-calls plan (2026-09-06, D6): the replay set is the trace channel plus the
// three outbound channels (the script's API / DB / notification calls already sit in
// the tree under the run's execution id) plus the inbound row. SecuredWrite, Cluster
// and InboundAuthFailure rows sharing an execution id stay out — nothing the flow
// graph can draw.
.Where(r => r.Channel == AuditChannel.ScriptTrace
|| r.Channel == AuditChannel.ApiOutbound
|| r.Channel == AuditChannel.DbOutbound
|| r.Channel == AuditChannel.Notification
|| (r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest))
.OrderBy(r => r.OccurredAtUtc)
.ThenBy(r => r.EventId);
const int maxRows = 500;
const int maxBytes = 1024 * 1024 - 4096; // Reserve envelope/array metadata.
var events = new List<AuditEvent>();
var bytes = 0;
var truncated = chainTruncated;
// Small keyset pages also bound EF execution-strategy buffering. Reading
// all 501 candidate rows in one query could buffer hundreds of MiB before
// the consumer reaches its byte cutoff. Each page projects only details
// within the remaining budget; oversized events are omitted whole.
const int pageSize = 8;
DateTime? afterTime = null;
Guid afterId = default;
while (true)
{
var remaining = maxBytes - bytes;
var pageQuery = afterTime is { } cursor
? query.Where(r => r.OccurredAtUtc > cursor || (r.OccurredAtUtc == cursor && r.EventId.CompareTo(afterId) > 0))
: query;
var page = await pageQuery.OrderBy(r => r.OccurredAtUtc).ThenBy(r => r.EventId)
.Take(Math.Min(pageSize, maxRows + 1 - events.Count)).Select(r => new {
Row = new AuditLogRow {
EventId = r.EventId, OccurredAtUtc = r.OccurredAtUtc, Actor = r.Actor, Action = r.Action,
Outcome = r.Outcome, Channel = r.Channel, Target = r.Target, SourceNode = r.SourceNode,
CorrelationId = r.CorrelationId,
DetailsJson = r.DetailsJson != null && r.DetailsJson.Length <= remaining ? r.DetailsJson : null
}, DetailLength = r.DetailsJson == null ? 0 : r.DetailsJson.Length
}).ToListAsync(ct);
if (page.Count == 0) break;
foreach (var item in page)
{
if (item.DetailLength > remaining || events.Count >= maxRows)
return new TraceEventBatch(rootId, events, true);
var evt = RowToCanonical(item.Row);
var size = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(evt).Length;
if (size > maxBytes - bytes) return new TraceEventBatch(rootId, events, true);
events.Add(evt); bytes += size;
afterTime = item.Row.OccurredAtUtc; afterId = item.Row.EventId;
}
if (page.Count < pageSize) break;
}
return new TraceEventBatch(rootId, events, truncated);
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)
.Distinct()
.OrderBy(n => n)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<long> BackfillSourceNodeAsync(
string sentinel,
DateTime before,
int batchSize,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(sentinel))
{
throw new ArgumentException("Sentinel must be a non-empty value.", nameof(sentinel));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
}
var beforeUtc = DateTime.SpecifyKind(before.ToUniversalTime(), DateTimeKind.Utc);
// SourceNode sentinel backfill. This is the ONE sanctioned UPDATE
// against dbo.AuditLog in the codebase. It touches ONLY rows where
// SourceNode IS NULL AND OccurredAtUtc < @before — rows that pre-date the
// SourceNode feature and whose node-of-origin is UNKNOWABLE. The sentinel (default
// "unknown") makes that explicit. ExecutionId/ParentExecutionId are PERSISTED
// COMPUTED columns derived from DetailsJson — mutating DetailsJson is forbidden
// under the append-only invariant, so those stay NULL on pre-feature rows.
//
// Maintenance path (NOT the writer role): runs on the same connection used for
// SwitchOutPartitionAsync (partition-switch DDL), which requires a role that
// holds UPDATE — the append-only scadabridge_audit_writer role has only
// INSERT + SELECT.
//
// Bounded + idempotent: UPDATE TOP (@batch) caps the log/lock footprint per
// statement; the loop exits when a batch updates 0 rows. Re-running after a
// crash simply resumes where it left off.
//
// The trailing AUDIT-PURGE-ALLOWED marker on the UPDATE line below is the
// single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests)
// recognises for an UPDATE; any other UPDATE targeting AuditLog still trips the guard.
const string updateBatchSql =
"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;"; // AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill, maintenance path
long totalUpdated = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
while (true)
{
ct.ThrowIfCancellationRequested();
await using var cmd = conn.CreateCommand();
cmd.CommandText = updateBatchSql;
var pBatch = cmd.CreateParameter();
pBatch.ParameterName = "@batch";
pBatch.Value = batchSize;
cmd.Parameters.Add(pBatch);
var pSentinel = cmd.CreateParameter();
pSentinel.ParameterName = "@sentinel";
pSentinel.Value = sentinel;
cmd.Parameters.Add(pSentinel);
var pBefore = cmd.CreateParameter();
pBefore.ParameterName = "@before";
pBefore.Value = beforeUtc;
cmd.Parameters.Add(pBefore);
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
if (rows <= 0)
{
break;
}
totalUpdated += rows;
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return totalUpdated;
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
/// list rather than a single empty string.
/// </summary>
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
{
if (string.IsNullOrEmpty(aggregate))
{
return Array.Empty<string>();
}
return aggregate
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToArray();
}
}