refactor(auditlog): GetExecutionTreeAsync recurses over a distinct edge set
This commit is contained in:
@@ -576,18 +576,22 @@ VALUES
|
|||||||
/// climbs from the supplied node to the root — the last execution id with no
|
/// climbs from the supplied node to the root — the last execution id with no
|
||||||
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
|
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
|
||||||
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
|
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
|
||||||
/// down:</b> a recursive CTE seeded at the root joins
|
/// down:</b> a recursive CTE over a DISTINCT
|
||||||
/// <c>child.ParentExecutionId = parent.ExecutionId</c> to enumerate every
|
/// <c>(ExecutionId, ParentExecutionId)</c> edge set, seeded at the root edge
|
||||||
/// descendant, bounded by <c>OPTION (MAXRECURSION 32)</c> — corrupt cyclic
|
/// and joining <c>edge.ParentExecutionId = chain.ExecutionId</c> to
|
||||||
/// data raises a <see cref="SqlException"/> (msg 530) rather than spinning.
|
/// enumerate every descendant. Recursing over edges rather than raw rows
|
||||||
|
/// keeps the recursion one path wide per execution. It is bounded by
|
||||||
|
/// <c>OPTION (MAXRECURSION 32)</c> — corrupt cyclic data raises a
|
||||||
|
/// <see cref="SqlException"/> (msg 530) rather than spinning.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The chain's full execution-id set is the union of the rows'
|
/// The chain's full execution-id set is every edge's <c>ExecutionId</c>
|
||||||
/// <c>ExecutionId</c> and their <c>ParentExecutionId</c>, so an execution
|
/// unioned with its non-null <c>ParentExecutionId</c>, so an execution
|
||||||
/// referenced only as a parent — a "stub" that emitted no rows of its own —
|
/// 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
|
/// and therefore owns no edge of its own — is still included. The final
|
||||||
/// <c>AuditLog</c> and <c>GROUP BY</c>s, so a stub yields a node with
|
/// projection LEFT JOINs that id set back to <c>AuditLog</c> and
|
||||||
/// <c>RowCount = 0</c> and empty/null aggregates. The query is SELECT-only
|
/// <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).
|
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -639,36 +643,61 @@ VALUES
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||||
// Chain : seeded at the root, recursively pulls every distinct
|
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
||||||
// ExecutionId whose rows carry a ParentExecutionId already
|
// edge set distilled from AuditLog. Recursing over edges
|
||||||
// in the chain. SELECT DISTINCT in the recursive member is
|
// instead of raw rows means an execution with N audit rows
|
||||||
// rejected by SQL Server, so the recursion walks raw rows
|
// contributes ONE recursion path, not N — MAXRECURSION
|
||||||
// and the outer query de-duplicates.
|
// bounds depth, not per-level width, so the raw-row form
|
||||||
// ChainIds: the chain's full execution-id set = every ExecutionId in
|
// could fan out badly. One edge per execution because all
|
||||||
// Chain UNIONed with every non-null ParentExecutionId — the
|
// rows of an execution share a single ParentExecutionId
|
||||||
// UNION pulls in stub parents that emitted no rows.
|
// (see the MIN(...) note on the final projection).
|
||||||
// Final : LEFT JOIN ChainIds back to AuditLog and GROUP BY so a
|
// Chain : seeded at the root edge, recursively joins each edge whose
|
||||||
// stub surfaces with RowCount 0 and NULL aggregates.
|
// 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.
|
||||||
|
// Final : 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>();
|
var nodes = new List<ExecutionTreeNode>();
|
||||||
await using (var downCmd = conn.CreateCommand())
|
await using (var downCmd = conn.CreateCommand())
|
||||||
{
|
{
|
||||||
downCmd.CommandText = @"
|
downCmd.CommandText = $@"
|
||||||
WITH Chain AS (
|
WITH Edges AS (
|
||||||
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId
|
SELECT DISTINCT ExecutionId, ParentExecutionId
|
||||||
|
FROM dbo.AuditLog
|
||||||
|
WHERE ExecutionId IS NOT NULL
|
||||||
|
),
|
||||||
|
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
|
UNION ALL
|
||||||
SELECT a.ExecutionId
|
SELECT e.ExecutionId, e.ParentExecutionId
|
||||||
FROM dbo.AuditLog a
|
FROM Edges e
|
||||||
INNER JOIN Chain c ON a.ParentExecutionId = c.ExecutionId
|
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
|
||||||
WHERE a.ExecutionId IS NOT NULL
|
|
||||||
),
|
),
|
||||||
ChainIds AS (
|
ChainIds AS (
|
||||||
SELECT DISTINCT ExecutionId FROM Chain
|
SELECT ExecutionId FROM Chain
|
||||||
UNION
|
UNION
|
||||||
SELECT DISTINCT a.ParentExecutionId
|
SELECT ParentExecutionId FROM Chain
|
||||||
FROM dbo.AuditLog a
|
WHERE ParentExecutionId IS NOT NULL
|
||||||
INNER JOIN Chain c ON a.ExecutionId = c.ExecutionId
|
|
||||||
WHERE a.ParentExecutionId IS NOT NULL
|
|
||||||
)
|
)
|
||||||
|
-- ParentExecutionId / SourceSiteId / SourceInstanceId are
|
||||||
|
-- derived via MIN: every audit row of one execution carries
|
||||||
|
-- the SAME ParentExecutionId (and source identity) — it is
|
||||||
|
-- stamped once per script run / inbound request — so MIN
|
||||||
|
-- simply picks that one shared value, it is not collapsing a
|
||||||
|
-- genuine disagreement across rows.
|
||||||
SELECT
|
SELECT
|
||||||
ids.ExecutionId AS [ExecutionId],
|
ids.ExecutionId AS [ExecutionId],
|
||||||
MIN(a.ParentExecutionId) AS [ParentExecutionId],
|
MIN(a.ParentExecutionId) AS [ParentExecutionId],
|
||||||
@@ -686,7 +715,7 @@ VALUES
|
|||||||
FROM ChainIds ids
|
FROM ChainIds ids
|
||||||
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
|
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
|
||||||
GROUP BY ids.ExecutionId
|
GROUP BY ids.ExecutionId
|
||||||
OPTION (MAXRECURSION 32);";
|
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
|
||||||
|
|
||||||
var pRoot = downCmd.CreateParameter();
|
var pRoot = downCmd.CreateParameter();
|
||||||
pRoot.ParameterName = "@root";
|
pRoot.ParameterName = "@root";
|
||||||
|
|||||||
@@ -770,18 +770,28 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
|
|
||||||
// A 3-level chain: root -> mid -> leaf. Each execution emits two rows so
|
// A 3-level chain: root -> mid -> leaf. Each execution emits two rows so
|
||||||
// RowCount aggregation is exercised; the child rows carry the parent's
|
// RowCount aggregation is exercised; the child rows carry the parent's
|
||||||
// ExecutionId as ParentExecutionId.
|
// ExecutionId as ParentExecutionId. Each execution is given a DISTINCT
|
||||||
|
// channel, and its two rows carry DISTINCT statuses and timestamps, so
|
||||||
|
// the per-node Channels/Statuses sets and the FirstOccurred/LastOccurred
|
||||||
|
// span are meaningfully asserted (not all-defaults).
|
||||||
var rootExec = Guid.NewGuid();
|
var rootExec = Guid.NewGuid();
|
||||||
var midExec = Guid.NewGuid();
|
var midExec = Guid.NewGuid();
|
||||||
var leafExec = Guid.NewGuid();
|
var leafExec = Guid.NewGuid();
|
||||||
|
|
||||||
var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
|
var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
|
||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec));
|
var rootT0 = t0;
|
||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: rootExec));
|
var rootT1 = t0.AddMinutes(1);
|
||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: midExec, parentExecutionId: rootExec));
|
var midT0 = t0.AddMinutes(2);
|
||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: midExec, parentExecutionId: rootExec));
|
var midT1 = t0.AddMinutes(3);
|
||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(4), executionId: leafExec, parentExecutionId: midExec));
|
var leafT0 = t0.AddMinutes(4);
|
||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: leafExec, parentExecutionId: midExec));
|
var leafT1 = t0.AddMinutes(5);
|
||||||
|
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT0, channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted, executionId: rootExec));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT1, channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered, executionId: rootExec));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT0, channel: AuditChannel.DbOutbound, status: AuditStatus.Submitted, executionId: midExec, parentExecutionId: rootExec));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT1, channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, executionId: midExec, parentExecutionId: rootExec));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT0, channel: AuditChannel.Notification, status: AuditStatus.Submitted, executionId: leafExec, parentExecutionId: midExec));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT1, channel: AuditChannel.Notification, status: AuditStatus.Parked, executionId: leafExec, parentExecutionId: midExec));
|
||||||
|
|
||||||
var expected = new[] { rootExec, midExec, leafExec };
|
var expected = new[] { rootExec, midExec, leafExec };
|
||||||
|
|
||||||
@@ -807,6 +817,37 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal(2, root.RowCount);
|
Assert.Equal(2, root.RowCount);
|
||||||
Assert.Equal(2, mid.RowCount);
|
Assert.Equal(2, mid.RowCount);
|
||||||
Assert.Equal(2, leaf.RowCount);
|
Assert.Equal(2, leaf.RowCount);
|
||||||
|
|
||||||
|
// Each populated node aggregates its own rows' channels and
|
||||||
|
// statuses — distinct per execution, so a regression that mixes
|
||||||
|
// executions or drops the per-id aggregate would be caught.
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { nameof(AuditChannel.ApiOutbound) },
|
||||||
|
root.Channels);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { nameof(AuditChannel.DbOutbound) },
|
||||||
|
mid.Channels);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { nameof(AuditChannel.Notification) },
|
||||||
|
leaf.Channels);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Delivered) }
|
||||||
|
.ToHashSet().SetEquals(root.Statuses));
|
||||||
|
Assert.True(
|
||||||
|
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Failed) }
|
||||||
|
.ToHashSet().SetEquals(mid.Statuses));
|
||||||
|
Assert.True(
|
||||||
|
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Parked) }
|
||||||
|
.ToHashSet().SetEquals(leaf.Statuses));
|
||||||
|
|
||||||
|
// Each populated node's timestamp span covers exactly its two rows.
|
||||||
|
Assert.Equal(rootT0, root.FirstOccurredAtUtc);
|
||||||
|
Assert.Equal(rootT1, root.LastOccurredAtUtc);
|
||||||
|
Assert.Equal(midT0, mid.FirstOccurredAtUtc);
|
||||||
|
Assert.Equal(midT1, mid.LastOccurredAtUtc);
|
||||||
|
Assert.Equal(leafT0, leaf.FirstOccurredAtUtc);
|
||||||
|
Assert.Equal(leafT1, leaf.LastOccurredAtUtc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user