diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index 113a4a44..715f8368 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -1,4 +1,5 @@
using System.Data;
+using System.Data.Common;
using System.Text;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
@@ -918,105 +919,114 @@ VALUES
private static readonly TimeSpan ExecutionTreeWindowSlack = TimeSpan.FromHours(1);
private static readonly TimeSpan ExecutionTreeMaxSpan = TimeSpan.FromDays(7);
- ///
- public async Task> GetExecutionTreeAsync(
- Guid executionId,
- CancellationToken ct = default)
+ ///
+ /// The anchor of one execution chain: the root execution id reached by the upward
+ /// ParentExecutionId walk, plus that root's first event timestamp (null when the
+ /// root is a row-less stub). Produced by and
+ /// consumed by / .
+ ///
+ private sealed record ChainAnchor(Guid RootExecutionId, DateTime? RootFirstOccurred)
{
- var conn = _context.Database.GetDbConnection();
- var openedHere = false;
- if (conn.State != System.Data.ConnectionState.Open)
+ ///
+ /// 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.
+ ///
+ public string EdgesWindowPredicate => RootFirstOccurred is null
+ ? string.Empty
+ : "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
+ }
+
+ ///
+ /// Phase 1 of the execution-chain walk, shared by and
+ /// (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.
+ ///
+ private static async Task ResolveChainAnchorAsync(
+ DbConnection conn,
+ Guid executionId,
+ CancellationToken ct)
+ {
+ // --- 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++)
{
- await conn.OpenAsync(ct).ConfigureAwait(false);
- openedHere = true;
+ 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;
}
- try
+ // --- 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())
{
- // --- 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);
+ winCmd.CommandText =
+ "SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
+ var pWinRoot = winCmd.CreateParameter();
+ pWinRoot.ParameterName = "@root";
+ pWinRoot.Value = rootExecutionId;
+ winCmd.Parameters.Add(pWinRoot);
- var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- parent = result is null or DBNull ? null : (Guid)result;
- }
+ var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
+ rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
+ }
- 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;
- }
+ return new ChainAnchor(rootExecutionId, rootFirstOccurred);
+ }
- 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 dbo.AuditLog WHERE ExecutionId = @root;";
- var pWinRoot = winCmd.CreateParameter();
- pWinRoot.ParameterName = "@root";
- pWinRoot.Value = rootExecutionId;
- winCmd.Parameters.Add(pWinRoot);
-
- var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
- }
-
- // Build 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.
- var edgesWindowPredicate = rootFirstOccurred is null
- ? string.Empty
- : "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
-
- // --- Phase 2: walk down from the root via a recursive CTE ---------
- // 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 final 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.
- // 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();
- await using (var downCmd = conn.CreateCommand())
- {
- downCmd.CommandText = $@"
+ ///
+ /// Phase 2's Edges/Chain CTE prefix — identical in both walkers (arch-review F9). Callers
+ /// append their own projection: the ChainIds CTE
+ /// plus the per-execution aggregate, the bare id union.
+ /// Both must terminate with OPTION (MAXRECURSION …).
+ ///
+ ///
+ /// 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.
+ ///
+ private static string ChainCtePrefix(string edgesWindowPredicate) => $@"
WITH Edges AS (
SELECT DISTINCT ExecutionId, ParentExecutionId
FROM dbo.AuditLog
@@ -1035,7 +1045,65 @@ VALUES
SELECT e.ExecutionId, e.ParentExecutionId
FROM Edges e
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
- ),
+ )";
+
+ ///
+ /// Binds the parameters the statement needs: @root always,
+ /// and the window bounds only when the anchor carries a root timestamp (matching
+ /// ).
+ ///
+ 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);
+ }
+ }
+
+ ///
+ public async Task> 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 + window: shared with GetTraceEventsAsync (arch-review F9) ---
+ var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).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();
+ await using (var downCmd = conn.CreateCommand())
+ {
+ downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@",
ChainIds AS (
SELECT ExecutionId FROM Chain
UNION
@@ -1071,23 +1139,7 @@ VALUES
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
- var pRoot = downCmd.CreateParameter();
- pRoot.ParameterName = "@root";
- pRoot.Value = rootExecutionId;
- downCmd.Parameters.Add(pRoot);
-
- if (rootFirstOccurred is { } rootFirst)
- {
- var pWinStart = downCmd.CreateParameter();
- pWinStart.ParameterName = "@winStart";
- pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
- downCmd.Parameters.Add(pWinStart);
-
- var pWinEnd = downCmd.CreateParameter();
- pWinEnd.ParameterName = "@winEnd";
- pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
- downCmd.Parameters.Add(pWinEnd);
- }
+ AddChainParameters(downCmd, anchor);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
@@ -1147,77 +1199,18 @@ VALUES
var chainIds = new List();
try
{
- 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) break;
- rootExecutionId = parent.Value;
- }
-
- DateTime? rootFirstOccurred;
- await using (var winCmd = conn.CreateCommand())
- {
- winCmd.CommandText = "SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
- var pWinRoot = winCmd.CreateParameter();
- pWinRoot.ParameterName = "@root";
- pWinRoot.Value = rootExecutionId;
- winCmd.Parameters.Add(pWinRoot);
- var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
- }
- var edgesWindowPredicate = rootFirstOccurred is null
- ? string.Empty
- : "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
+ var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
await using (var idsCmd = conn.CreateCommand())
{
- idsCmd.CommandText = $@"
- WITH Edges AS (
- SELECT DISTINCT ExecutionId, ParentExecutionId
- FROM dbo.AuditLog
- WHERE ExecutionId IS NOT NULL
- {edgesWindowPredicate}
- ),
- Chain AS (
- 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
- )
+ idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@"
SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
- var pRoot = idsCmd.CreateParameter();
- pRoot.ParameterName = "@root";
- pRoot.Value = rootExecutionId;
- idsCmd.Parameters.Add(pRoot);
- if (rootFirstOccurred is { } rootFirst)
- {
- var pWinStart = idsCmd.CreateParameter();
- pWinStart.ParameterName = "@winStart";
- pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
- idsCmd.Parameters.Add(pWinStart);
- var pWinEnd = idsCmd.CreateParameter();
- pWinEnd.ParameterName = "@winEnd";
- pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
- idsCmd.Parameters.Add(pWinEnd);
- }
+
+ AddChainParameters(idsCmd, anchor);
+
await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{