feat(auditlog): GetExecutionTreeAsync recursive execution-chain query
This commit is contained in:
@@ -134,4 +134,45 @@ public interface IAuditLogRepository
|
||||
TimeSpan window,
|
||||
DateTime? nowUtc = null,
|
||||
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);
|
||||
}
|
||||
|
||||
71
src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
Normal file
71
src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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