feat(auditlog): GetExecutionTreeAsync recursive execution-chain query
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user