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

@@ -134,4 +134,45 @@ public interface IAuditLogRepository
TimeSpan window, TimeSpan window,
DateTime? nowUtc = null, DateTime? nowUtc = null,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Audit Log ParentExecutionId feature (Task 8) — given any
/// <paramref name="executionId"/> in an execution chain, returns the whole
/// chain rooted at the topmost ancestor: one <see cref="ExecutionTreeNode"/>
/// per distinct execution, summarising its <c>AuditLog</c> rows. The Central
/// UI renders the result as a tree.
/// </summary>
/// <remarks>
/// <para>
/// The input id may be any node in the chain — a leaf, the root, or a middle
/// node. The implementation first walks <em>up</em> via
/// <c>ParentExecutionId</c> to find the root, then walks <em>down</em> from
/// the root via a recursive CTE, so the full chain is returned regardless of
/// entry point.
/// </para>
/// <para>
/// The <c>ParentExecutionId</c> graph is a tree (acyclic by construction —
/// each execution is minted fresh and its parent always pre-exists). Both
/// the upward walk and the downward CTE are nonetheless bounded at 32 levels
/// as a guard against corrupt/pathological data: a depth that exceeds the
/// guard raises an error rather than hanging the server. Chains are shallow
/// (1-2 levels typical) so the guard is never reached in practice.
/// </para>
/// <para>
/// A "stub" node — an execution that emitted no rows of its own yet is
/// referenced by a child via <c>ParentExecutionId</c>, or whose rows have
/// been purged — still appears, with <see cref="ExecutionTreeNode.RowCount"/>
/// = 0. A purged/missing parent simply ends the upward walk.
/// </para>
/// <para>
/// When no <c>AuditLog</c> row carries <paramref name="executionId"/> in
/// either <c>ExecutionId</c> or <c>ParentExecutionId</c>, the result is a
/// single stub node for <paramref name="executionId"/> itself
/// (<see cref="ExecutionTreeNode.RowCount"/> = 0) — consistent with the
/// stub-node treatment of any other row-less execution.
/// </para>
/// </remarks>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
} }

View File

@@ -0,0 +1,71 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// One execution within an execution chain returned by
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
/// Each node summarises the <c>AuditLog</c> rows sharing a single
/// <see cref="ExecutionId"/>; the Central UI renders the set as a tree by
/// joining <see cref="ParentExecutionId"/> to a parent node's
/// <see cref="ExecutionId"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Stub nodes.</b> An execution that performed a trust-boundary action but
/// crossed it without emitting any audit row — or whose own rows have been
/// purged — still appears as a node when a child references it via
/// <see cref="ParentExecutionId"/>. Such a stub node has <see cref="RowCount"/>
/// = 0, empty <see cref="Channels"/>/<see cref="Statuses"/>, null
/// <see cref="SourceSiteId"/>/<see cref="SourceInstanceId"/>, null timestamps,
/// and a null <see cref="ParentExecutionId"/> (a purged/ghost parent leaves no
/// row from which its own parent could be read — the upward walk ends there).
/// </para>
/// <para>
/// <see cref="Channels"/> and <see cref="Statuses"/> are the distinct sets of
/// the corresponding enum names present across the execution's rows, modelled
/// as <see cref="IReadOnlyList{T}"/> of string to mirror how the repository's
/// query filters already pass small bounded sets around.
/// </para>
/// </remarks>
/// <param name="ExecutionId">The execution this node summarises.</param>
/// <param name="ParentExecutionId">
/// The <see cref="ExecutionId"/> of the spawning execution, or null for the
/// root (and for stub nodes, whose own parent is unknowable).
/// </param>
/// <param name="RowCount">
/// Number of <c>AuditLog</c> rows carrying this <see cref="ExecutionId"/>; 0 for
/// a stub node.
/// </param>
/// <param name="Channels">
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditChannel"/> names
/// present across this execution's rows; empty for a stub node.
/// </param>
/// <param name="Statuses">
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/> names
/// present across this execution's rows; empty for a stub node.
/// </param>
/// <param name="SourceSiteId">
/// Source site of the execution's rows when consistent; null for a stub node
/// (or when the rows carry no site).
/// </param>
/// <param name="SourceInstanceId">
/// Source instance of the execution's rows when consistent; null for a stub
/// node (or when the rows carry no instance).
/// </param>
/// <param name="FirstOccurredAtUtc">
/// Earliest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
/// node.
/// </param>
/// <param name="LastOccurredAtUtc">
/// Latest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
/// node.
/// </param>
public sealed record ExecutionTreeNode(
Guid ExecutionId,
Guid? ParentExecutionId,
int RowCount,
IReadOnlyList<string> Channels,
IReadOnlyList<string> Statuses,
string? SourceSiteId,
string? SourceInstanceId,
DateTime? FirstOccurredAtUtc,
DateTime? LastOccurredAtUtc);

View File

@@ -555,4 +555,197 @@ VALUES
BacklogTotal: 0L, BacklogTotal: 0L,
AsOfUtc: anchorUtc); AsOfUtc: anchorUtc);
} }
// Hard ceiling on chain depth for both the upward walk and the downward
// recursive CTE. The ParentExecutionId graph is a tree (acyclic by
// construction — each execution is minted fresh, its parent always
// pre-exists), so this is purely a guard against corrupt/pathological data:
// a cycle must surface as a bounded error rather than hang the server.
// Chains are shallow (1-2 levels typical) so the guard is never reached in
// practice.
private const int ExecutionChainMaxDepth = 32;
/// <summary>
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
/// containing <paramref name="executionId"/>, regardless of entry point.
/// </summary>
/// <remarks>
/// <para>
/// Two phases. <b>Walk up:</b> an iterative
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
/// climbs from the supplied node to the root — the last execution id with no
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
/// down:</b> a recursive CTE seeded at the root joins
/// <c>child.ParentExecutionId = parent.ExecutionId</c> to enumerate every
/// descendant, bounded by <c>OPTION (MAXRECURSION 32)</c> — corrupt cyclic
/// data raises a <see cref="SqlException"/> (msg 530) rather than spinning.
/// </para>
/// <para>
/// The chain's full execution-id set is the union of the rows'
/// <c>ExecutionId</c> and their <c>ParentExecutionId</c>, so an execution
/// referenced only as a parent — a "stub" that emitted no rows of its own —
/// is included. The final projection LEFT JOINs that id set back to
/// <c>AuditLog</c> and <c>GROUP BY</c>s, so a stub yields a node with
/// <c>RowCount = 0</c> and empty/null aggregates. The query is SELECT-only
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
/// </para>
/// </remarks>
public async Task<IReadOnlyList<ExecutionTreeNode>> 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: 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);
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;
}
// --- Phase 2: walk down from the root via a recursive CTE ---------
// Chain : seeded at the root, recursively pulls every distinct
// ExecutionId whose rows carry a ParentExecutionId already
// in the chain. SELECT DISTINCT in the recursive member is
// rejected by SQL Server, so the recursion walks raw rows
// and the outer query de-duplicates.
// ChainIds: the chain's full execution-id set = every ExecutionId in
// Chain UNIONed with every non-null ParentExecutionId — the
// UNION pulls in stub parents that emitted no rows.
// Final : LEFT JOIN ChainIds back to AuditLog and GROUP BY so a
// stub surfaces with RowCount 0 and NULL aggregates.
var nodes = new List<ExecutionTreeNode>();
await using (var downCmd = conn.CreateCommand())
{
downCmd.CommandText = @"
WITH Chain AS (
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId
UNION ALL
SELECT a.ExecutionId
FROM dbo.AuditLog a
INNER JOIN Chain c ON a.ParentExecutionId = c.ExecutionId
WHERE a.ExecutionId IS NOT NULL
),
ChainIds AS (
SELECT DISTINCT ExecutionId FROM Chain
UNION
SELECT DISTINCT a.ParentExecutionId
FROM dbo.AuditLog a
INNER JOIN Chain c ON a.ExecutionId = c.ExecutionId
WHERE a.ParentExecutionId IS NOT NULL
)
SELECT
ids.ExecutionId AS [ExecutionId],
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Channel, ',')
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(a.SourceInstanceId) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION 32);";
var pRoot = downCmd.CreateParameter();
pRoot.ParameterName = "@root";
pRoot.Value = rootExecutionId;
downCmd.Parameters.Add(pRoot);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var nodeExecutionId = reader.GetGuid(0);
Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1);
var rowCount = reader.GetInt32(2);
var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3));
var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4));
var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5);
var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6);
DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7);
DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8);
nodes.Add(new ExecutionTreeNode(
ExecutionId: nodeExecutionId,
ParentExecutionId: parentExecutionId,
RowCount: rowCount,
Channels: channels,
Statuses: statuses,
SourceSiteId: sourceSiteId,
SourceInstanceId: sourceInstanceId,
FirstOccurredAtUtc: firstOccurred,
LastOccurredAtUtc: lastOccurred));
}
}
return nodes;
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
/// list rather than a single empty string.
/// </summary>
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
{
if (string.IsNullOrEmpty(aggregate))
{
return Array.Empty<string>();
}
return aggregate
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToArray();
}
} }

View File

@@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct); _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( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); 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) private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)

View File

@@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); 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> /// <summary>

View File

@@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); 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> /// <summary>

View File

@@ -746,6 +746,143 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nowUtc, snapshot.AsOfUtc); 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) private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
{ {
var conn = context.Database.GetDbConnection(); var conn = context.Database.GetDbConnection();

View File

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