perf(audit-log): bound GetExecutionTreeAsync edge scan to a root-anchored time window (partition elimination)
This commit is contained in:
@@ -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
|
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.
|
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)
|
## The Site-Local `AuditLog` (SQLite)
|
||||||
|
|
||||||
A SQLite database file on each site node, alongside the Store-and-Forward
|
A SQLite database file on each site node, alongside the Store-and-Forward
|
||||||
|
|||||||
@@ -665,6 +665,16 @@ VALUES
|
|||||||
// practice.
|
// practice.
|
||||||
private const int ExecutionChainMaxDepth = 32;
|
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);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId,
|
Guid executionId,
|
||||||
@@ -713,6 +723,31 @@ VALUES
|
|||||||
rootExecutionId = parent.Value;
|
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 ---------
|
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||||
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
||||||
// edge set distilled from AuditLog. Recursing over edges
|
// edge set distilled from AuditLog. Recursing over edges
|
||||||
@@ -743,6 +778,7 @@ VALUES
|
|||||||
SELECT DISTINCT ExecutionId, ParentExecutionId
|
SELECT DISTINCT ExecutionId, ParentExecutionId
|
||||||
FROM dbo.AuditLog
|
FROM dbo.AuditLog
|
||||||
WHERE ExecutionId IS NOT NULL
|
WHERE ExecutionId IS NOT NULL
|
||||||
|
{edgesWindowPredicate}
|
||||||
),
|
),
|
||||||
Chain AS (
|
Chain AS (
|
||||||
-- Anchor: the root execution id, seeded as a literal so
|
-- Anchor: the root execution id, seeded as a literal so
|
||||||
@@ -797,6 +833,19 @@ VALUES
|
|||||||
pRoot.Value = rootExecutionId;
|
pRoot.Value = rootExecutionId;
|
||||||
downCmd.Parameters.Add(pRoot);
|
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);
|
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
|||||||
+43
@@ -1008,6 +1008,49 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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]
|
[SkippableFact]
|
||||||
public async Task GetExecutionTree_StubParentNode()
|
public async Task GetExecutionTree_StubParentNode()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user