feat(auditlog): GetExecutionTreeAsync recursive execution-chain query

This commit is contained in:
Joseph Doherty
2026-05-21 18:21:49 -04:00
parent d35551efc2
commit 255dd95cd9
9 changed files with 462 additions and 0 deletions

View File

@@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
}
}

View File

@@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)

View File

@@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
}
/// <summary>

View File

@@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
}
/// <summary>

View File

@@ -746,6 +746,143 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nowUtc, snapshot.AsOfUtc);
}
// ------------------------------------------------------------------------
// Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync
// ------------------------------------------------------------------------
//
// GetExecutionTreeAsync walks UP from any node to the chain root, then walks
// DOWN via a recursive CTE, returning one ExecutionTreeNode per distinct
// execution in the chain. These tests verify the observable behaviour:
// * a multi-level chain returns the full set regardless of entry node
// * a parent referenced only via ParentExecutionId (no rows of its own)
// still surfaces, as a RowCount = 0 stub node
// * pathological cyclic data is bounded by the MAXRECURSION guard and
// surfaces a SqlException rather than hanging
[SkippableFact]
public async Task GetExecutionTree_MultiLevelChain()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// A 3-level chain: root -> mid -> leaf. Each execution emits two rows so
// RowCount aggregation is exercised; the child rows carry the parent's
// ExecutionId as ParentExecutionId.
var rootExec = Guid.NewGuid();
var midExec = Guid.NewGuid();
var leafExec = Guid.NewGuid();
var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: midExec, parentExecutionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: midExec, parentExecutionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(4), executionId: leafExec, parentExecutionId: midExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: leafExec, parentExecutionId: midExec));
var expected = new[] { rootExec, midExec, leafExec };
// Entry point must not matter: leaf, middle node, and root all yield the
// same full chain.
foreach (var entry in new[] { leafExec, midExec, rootExec })
{
var tree = await repo.GetExecutionTreeAsync(entry);
Assert.Equal(3, tree.Count);
Assert.True(
expected.ToHashSet().SetEquals(tree.Select(n => n.ExecutionId)),
$"entry {entry} did not return the full chain");
var root = tree.Single(n => n.ExecutionId == rootExec);
var mid = tree.Single(n => n.ExecutionId == midExec);
var leaf = tree.Single(n => n.ExecutionId == leafExec);
Assert.Null(root.ParentExecutionId);
Assert.Equal(rootExec, mid.ParentExecutionId);
Assert.Equal(midExec, leaf.ParentExecutionId);
Assert.Equal(2, root.RowCount);
Assert.Equal(2, mid.RowCount);
Assert.Equal(2, leaf.RowCount);
}
}
[SkippableFact]
public async Task GetExecutionTree_StubParentNode()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// The parent execution emitted no rows of its own (it performed no
// trust-boundary action, or its rows were purged). Only the child has
// rows, and they reference the parent via ParentExecutionId. The parent
// must still surface as a node — a RowCount = 0 stub.
var stubParentExec = Guid.NewGuid();
var childExec = Guid.NewGuid();
var t0 = new DateTime(2026, 10, 6, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: childExec, parentExecutionId: stubParentExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: childExec, parentExecutionId: stubParentExec));
// Entering by the child must surface BOTH the child and the stub parent.
var tree = await repo.GetExecutionTreeAsync(childExec);
Assert.Equal(2, tree.Count);
var stub = tree.Single(n => n.ExecutionId == stubParentExec);
var child = tree.Single(n => n.ExecutionId == childExec);
// The stub node: no rows, empty aggregate sets, null parent and timestamps.
Assert.Equal(0, stub.RowCount);
Assert.Empty(stub.Channels);
Assert.Empty(stub.Statuses);
Assert.Null(stub.ParentExecutionId);
Assert.Null(stub.FirstOccurredAtUtc);
Assert.Null(stub.LastOccurredAtUtc);
// The child node carries its rows and points at the stub parent.
Assert.Equal(2, child.RowCount);
Assert.Equal(stubParentExec, child.ParentExecutionId);
}
[SkippableFact]
public async Task GetExecutionTree_RespectsMaxRecursion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// Pathological cyclic data: A's rows point at B as parent, B's rows
// point back at A. The ParentExecutionId graph is acyclic by
// construction in production, but corrupt data must not hang the
// server. The downward recursive CTE's OPTION (MAXRECURSION 32) raises
// a SqlException when the cycle exceeds the guard; the method surfaces
// it rather than spinning forever.
var execA = Guid.NewGuid();
var execB = Guid.NewGuid();
var t0 = new DateTime(2026, 10, 7, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: execA, parentExecutionId: execB));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: execB, parentExecutionId: execA));
// The call must complete (throw) quickly, not hang. A generous 30s
// ceiling distinguishes "bounded failure" from "infinite loop".
var call = repo.GetExecutionTreeAsync(execA);
var completed = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30)));
Assert.Same(call, completed);
// MAXRECURSION exceeded surfaces as a SqlException — bounded, not a hang.
await Assert.ThrowsAsync<SqlException>(() => call);
}
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
{
var conn = context.Database.GetDbConnection();

View File

@@ -96,6 +96,10 @@ public class SiteAuditPushFlowTests : TestKit
public Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static AuditEvent NewPendingEvent(Guid id) => new()