feat(auditlog): GetExecutionTreeAsync recursive execution-chain query
This commit is contained in:
@@ -555,4 +555,197 @@ VALUES
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
|
||||
/// containing <paramref name="executionId"/>, regardless of entry point.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two phases. <b>Walk up:</b> an iterative
|
||||
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
|
||||
/// climbs from the supplied node to the root — the last execution id with no
|
||||
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
|
||||
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
|
||||
/// down:</b> a recursive CTE seeded at the root joins
|
||||
/// <c>child.ParentExecutionId = parent.ExecutionId</c> to enumerate every
|
||||
/// descendant, bounded by <c>OPTION (MAXRECURSION 32)</c> — corrupt cyclic
|
||||
/// data raises a <see cref="SqlException"/> (msg 530) rather than spinning.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The chain's full execution-id set is the union of the rows'
|
||||
/// <c>ExecutionId</c> and their <c>ParentExecutionId</c>, so an execution
|
||||
/// referenced only as a parent — a "stub" that emitted no rows of its own —
|
||||
/// is included. The final projection LEFT JOINs that id set back to
|
||||
/// <c>AuditLog</c> and <c>GROUP BY</c>s, so a stub yields a node with
|
||||
/// <c>RowCount = 0</c> and empty/null aggregates. The query is SELECT-only
|
||||
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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: 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 dbo.AuditLog " +
|
||||
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
|
||||
var pCur = upCmd.CreateParameter();
|
||||
pCur.ParameterName = "@cur";
|
||||
pCur.Value = rootExecutionId;
|
||||
upCmd.Parameters.Add(pCur);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||
// Chain : seeded at the root, recursively pulls every distinct
|
||||
// ExecutionId whose rows carry a ParentExecutionId already
|
||||
// in the chain. SELECT DISTINCT in the recursive member is
|
||||
// rejected by SQL Server, so the recursion walks raw rows
|
||||
// and the outer query de-duplicates.
|
||||
// ChainIds: the chain's full execution-id set = every ExecutionId in
|
||||
// Chain UNIONed with every non-null ParentExecutionId — the
|
||||
// UNION pulls in stub parents that emitted no rows.
|
||||
// Final : LEFT JOIN ChainIds back to AuditLog and GROUP BY so a
|
||||
// stub surfaces with RowCount 0 and NULL aggregates.
|
||||
var nodes = new List<ExecutionTreeNode>();
|
||||
await using (var downCmd = conn.CreateCommand())
|
||||
{
|
||||
downCmd.CommandText = @"
|
||||
WITH Chain AS (
|
||||
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId
|
||||
UNION ALL
|
||||
SELECT a.ExecutionId
|
||||
FROM dbo.AuditLog a
|
||||
INNER JOIN Chain c ON a.ParentExecutionId = c.ExecutionId
|
||||
WHERE a.ExecutionId IS NOT NULL
|
||||
),
|
||||
ChainIds AS (
|
||||
SELECT DISTINCT ExecutionId FROM Chain
|
||||
UNION
|
||||
SELECT DISTINCT a.ParentExecutionId
|
||||
FROM dbo.AuditLog a
|
||||
INNER JOIN Chain c ON a.ExecutionId = c.ExecutionId
|
||||
WHERE a.ParentExecutionId IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
ids.ExecutionId AS [ExecutionId],
|
||||
MIN(a.ParentExecutionId) AS [ParentExecutionId],
|
||||
COUNT(a.EventId) AS [RowCount],
|
||||
(SELECT STRING_AGG(d.Channel, ',')
|
||||
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
|
||||
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
|
||||
(SELECT STRING_AGG(d.Status, ',')
|
||||
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
|
||||
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
|
||||
MIN(a.SourceSiteId) AS [SourceSiteId],
|
||||
MIN(a.SourceInstanceId) AS [SourceInstanceId],
|
||||
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
|
||||
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
|
||||
FROM ChainIds ids
|
||||
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
|
||||
GROUP BY ids.ExecutionId
|
||||
OPTION (MAXRECURSION 32);";
|
||||
|
||||
var pRoot = downCmd.CreateParameter();
|
||||
pRoot.ParameterName = "@root";
|
||||
pRoot.Value = rootExecutionId;
|
||||
downCmd.Parameters.Add(pRoot);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user