diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
index 36b0d0f..13fd96b 100644
--- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
+++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs
@@ -134,4 +134,45 @@ public interface IAuditLogRepository
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default);
+
+ ///
+ /// Audit Log ParentExecutionId feature (Task 8) — given any
+ /// in an execution chain, returns the whole
+ /// chain rooted at the topmost ancestor: one
+ /// per distinct execution, summarising its AuditLog rows. The Central
+ /// UI renders the result as a tree.
+ ///
+ ///
+ ///
+ /// The input id may be any node in the chain — a leaf, the root, or a middle
+ /// node. The implementation first walks up via
+ /// ParentExecutionId to find the root, then walks down from
+ /// the root via a recursive CTE, so the full chain is returned regardless of
+ /// entry point.
+ ///
+ ///
+ /// The ParentExecutionId 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.
+ ///
+ ///
+ /// A "stub" node — an execution that emitted no rows of its own yet is
+ /// referenced by a child via ParentExecutionId, or whose rows have
+ /// been purged — still appears, with
+ /// = 0. A purged/missing parent simply ends the upward walk.
+ ///
+ ///
+ /// When no AuditLog row carries in
+ /// either ExecutionId or ParentExecutionId, the result is a
+ /// single stub node for itself
+ /// ( = 0) — consistent with the
+ /// stub-node treatment of any other row-less execution.
+ ///
+ ///
+ Task> GetExecutionTreeAsync(
+ Guid executionId,
+ CancellationToken ct = default);
}
diff --git a/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs b/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
new file mode 100644
index 0000000..efce45f
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
@@ -0,0 +1,71 @@
+namespace ScadaLink.Commons.Types.Audit;
+
+///
+/// One execution within an execution chain returned by
+/// .
+/// Each node summarises the AuditLog rows sharing a single
+/// ; the Central UI renders the set as a tree by
+/// joining to a parent node's
+/// .
+///
+///
+///
+/// Stub nodes. 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
+/// . Such a stub node has
+/// = 0, empty /, null
+/// /, null timestamps,
+/// and a null (a purged/ghost parent leaves no
+/// row from which its own parent could be read — the upward walk ends there).
+///
+///
+/// and are the distinct sets of
+/// the corresponding enum names present across the execution's rows, modelled
+/// as of string to mirror how the repository's
+/// query filters already pass small bounded sets around.
+///
+///
+/// The execution this node summarises.
+///
+/// The of the spawning execution, or null for the
+/// root (and for stub nodes, whose own parent is unknowable).
+///
+///
+/// Number of AuditLog rows carrying this ; 0 for
+/// a stub node.
+///
+///
+/// Distinct names
+/// present across this execution's rows; empty for a stub node.
+///
+///
+/// Distinct names
+/// present across this execution's rows; empty for a stub node.
+///
+///
+/// Source site of the execution's rows when consistent; null for a stub node
+/// (or when the rows carry no site).
+///
+///
+/// Source instance of the execution's rows when consistent; null for a stub
+/// node (or when the rows carry no instance).
+///
+///
+/// Earliest OccurredAtUtc across this execution's rows; null for a stub
+/// node.
+///
+///
+/// Latest OccurredAtUtc across this execution's rows; null for a stub
+/// node.
+///
+public sealed record ExecutionTreeNode(
+ Guid ExecutionId,
+ Guid? ParentExecutionId,
+ int RowCount,
+ IReadOnlyList Channels,
+ IReadOnlyList Statuses,
+ string? SourceSiteId,
+ string? SourceInstanceId,
+ DateTime? FirstOccurredAtUtc,
+ DateTime? LastOccurredAtUtc);
diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
index 2c5cabf..85afb46 100644
--- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs
@@ -555,4 +555,197 @@ VALUES
BacklogTotal: 0L,
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;
+
+ ///
+ /// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
+ /// containing , regardless of entry point.
+ ///
+ ///
+ ///
+ /// Two phases. Walk up: an iterative
+ /// SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL
+ /// climbs from the supplied node to the root — the last execution id with no
+ /// parent. The loop is capped at
+ /// iterations; a purged/missing parent simply ends the climb early. Walk
+ /// down: a recursive CTE seeded at the root joins
+ /// child.ParentExecutionId = parent.ExecutionId to enumerate every
+ /// descendant, bounded by OPTION (MAXRECURSION 32) — corrupt cyclic
+ /// data raises a (msg 530) rather than spinning.
+ ///
+ ///
+ /// The chain's full execution-id set is the union of the rows'
+ /// ExecutionId and their ParentExecutionId, 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
+ /// AuditLog and GROUP BYs, so a stub yields a node with
+ /// RowCount = 0 and empty/null aggregates. The query is SELECT-only
+ /// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
+ ///
+ ///
+ public async Task> 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();
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// Splits a STRING_AGG 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.
+ ///
+ private static IReadOnlyList SplitAggregate(string? aggregate)
+ {
+ if (string.IsNullOrEmpty(aggregate))
+ {
+ return Array.Empty();
+ }
+
+ return aggregate
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(v => v, StringComparer.Ordinal)
+ .ToArray();
+ }
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
index 51a0bb7..32a6606 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs
@@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
+
+ public Task> GetExecutionTreeAsync(
+ Guid executionId, CancellationToken ct = default) =>
+ _inner.GetExecutionTreeAsync(executionId, ct);
}
}
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
index 241b720..290ff16 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs
@@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
+
+ public Task> GetExecutionTreeAsync(
+ Guid executionId, CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
index b4d3569..4ad28a8 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs
@@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
+
+ public Task> GetExecutionTreeAsync(
+ Guid executionId, CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
///
diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
index 87b5024..f8b3b49 100644
--- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
+++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs
@@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
+
+ public Task> GetExecutionTreeAsync(
+ Guid executionId, CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
}
///
diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
index 01ab04d..f4ddc4a 100644
--- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
+++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs
@@ -746,6 +746,143 @@ public class AuditLogRepositoryTests : IClassFixture
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(() => call);
+ }
+
private async Task ScalarAsync(ScadaLinkDbContext context, string sql)
{
var conn = context.Database.GetDbConnection();
diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
index 05b2693..be01f04 100644
--- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
+++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs
@@ -96,6 +96,10 @@ public class SiteAuditPushFlowTests : TestKit
public Task GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
=> throw new NotSupportedException();
+
+ public Task> GetExecutionTreeAsync(
+ Guid executionId, CancellationToken ct = default)
+ => throw new NotSupportedException();
}
private static AuditEvent NewPendingEvent(Guid id) => new()