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)) {