From 5d0fcd4e66b4f5d96cd01f416638af9ea882c085 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 07:10:35 -0400 Subject: [PATCH] perf(audit-log): bound GetExecutionTreeAsync edge scan to a root-anchored time window (partition elimination) --- docs/requirements/Component-AuditLog.md | 11 +++++ .../Repositories/AuditLogRepository.cs | 49 +++++++++++++++++++ .../Repositories/AuditLogRepositoryTests.cs | 43 ++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index d3db0453..88267907 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -209,6 +209,17 @@ tag subscription) are also wired: trigger-driven runs carry `ParentExecutionId = NULL` (top-level roots), and any nested `CallScript`/`CallShared` they perform chains as above. The schema is unchanged — no further tag-cascade work is deferred. +**Execution-tree traversal bound.** `GetExecutionTreeAsync` first walks up +`ParentExecutionId` to the chain root, then walks down via a recursive CTE. The +down-walk's edge scan is bounded to a window anchored at the root's **first event**: +`[rootFirst − 1 h, rootFirst + 7 d)` (configurable constants). Execution trees span +minutes, not years — an inbound request and everything it spawns fire inside one +operational burst — so the window lets SQL Server eliminate every `AuditLog` +partition outside it, turning a full-table `DISTINCT` scan into a seek on +`IX_AuditLog_Execution` (arch-review 04, P2). A genuine descendant stamped **beyond +the 7-day span is excluded by design**; a row-less stub root (its first event purged) +has no anchor and falls back to the unbounded scan (correctness over speed). + ## The Site-Local `AuditLog` (SQLite) A SQLite database file on each site node, alongside the Store-and-Forward diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs index f3755a0d..9857b9d1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -665,6 +665,16 @@ VALUES // 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); + /// public async Task> GetExecutionTreeAsync( Guid executionId, @@ -713,6 +723,31 @@ VALUES 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 @@ -743,6 +778,7 @@ VALUES SELECT DISTINCT ExecutionId, ParentExecutionId FROM dbo.AuditLog WHERE ExecutionId IS NOT NULL + {edgesWindowPredicate} ), Chain AS ( -- Anchor: the root execution id, seeded as a literal so @@ -797,6 +833,19 @@ VALUES 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); + } + await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await reader.ReadAsync(ct).ConfigureAwait(false)) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index bf125bdf..adda88ba 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -1008,6 +1008,49 @@ public class AuditLogRepositoryTests : IClassFixture } } + [SkippableFact] + public async Task ExecutionTree_Bounds_EdgeScan_To_Root_Window() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // arch-review 04 (P2): the down-walk Edges CTE is anchored to a + // [rootFirst - 1h, rootFirst + 7d) window around the root's first event, enabling + // partition elimination instead of a full-table DISTINCT scan. A genuine descendant + // stamped BEYOND that 7-day span is excluded by design; a normal in-window child is + // still returned. + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var rootExec = Guid.NewGuid(); + var childExec = Guid.NewGuid(); + var farDescendantExec = Guid.NewGuid(); + var unrelatedExec = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc); + + // Root + an in-window child (T0 + 5 min) — both must be returned. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: childExec, parentExecutionId: rootExec)); + + // A genuine descendant of the child, but stamped 30 days out — beyond the 7-day + // traversal bound, so it is documented-excluded from the tree. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddDays(30), executionId: farDescendantExec, parentExecutionId: childExec)); + + // An unrelated execution far in time sharing no ancestry — never part of the chain + // (asserts the window bound does not accidentally pull in strangers). + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddDays(30), executionId: unrelatedExec)); + + var tree = await repo.GetExecutionTreeAsync(rootExec); + var ids = tree.Select(n => n.ExecutionId).ToHashSet(); + + Assert.Contains(rootExec, ids); + Assert.Contains(childExec, ids); + Assert.DoesNotContain(farDescendantExec, ids); + Assert.DoesNotContain(unrelatedExec, ids); + Assert.Equal(2, tree.Count); + } + [SkippableFact] public async Task GetExecutionTree_StubParentNode() {