Merge branch 'worktree-agent-a0f225cd55f5aa409' into fix/flow-debugger-arch-review
This commit is contained in:
+166
-173
@@ -1,4 +1,5 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -918,105 +919,114 @@ VALUES
|
||||
private static readonly TimeSpan ExecutionTreeWindowSlack = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan ExecutionTreeMaxSpan = TimeSpan.FromDays(7);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default)
|
||||
/// <summary>
|
||||
/// The anchor of one execution chain: the root execution id reached by the upward
|
||||
/// <c>ParentExecutionId</c> walk, plus that root's first event timestamp (null when the
|
||||
/// root is a row-less stub). Produced by <see cref="ResolveChainAnchorAsync"/> and
|
||||
/// consumed by <see cref="ChainCtePrefix"/> / <see cref="AddChainParameters"/>.
|
||||
/// </summary>
|
||||
private sealed record ChainAnchor(Guid RootExecutionId, DateTime? RootFirstOccurred)
|
||||
{
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
/// <summary>
|
||||
/// The optional window predicate for the Edges CTE. When the root has rows the scan is
|
||||
/// confined to the window (partition elimination); otherwise it stays unbounded.
|
||||
/// </summary>
|
||||
public string EdgesWindowPredicate => RootFirstOccurred is null
|
||||
? string.Empty
|
||||
: "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 1 of the execution-chain walk, shared by <see cref="GetExecutionTreeAsync"/> and
|
||||
/// <see cref="GetTraceEventsAsync"/> (arch-review F9): climb to the chain root, then read the
|
||||
/// root's first event timestamp so the down-walk can be bounded to a root-anchored window.
|
||||
/// The two walkers must stay in step on window slack, depth cap and the DISTINCT-edges trick,
|
||||
/// so there is exactly one copy of each.
|
||||
/// </summary>
|
||||
private static async Task<ChainAnchor> ResolveChainAnchorAsync(
|
||||
DbConnection conn,
|
||||
Guid executionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// --- 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++)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
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;
|
||||
}
|
||||
|
||||
try
|
||||
// --- Between phases: bound the down-walk to a root-anchored window ---
|
||||
// The root's first event timestamp anchors a [rootFirst - slack, rootFirst + maxSpan)
|
||||
// window used to prune the Edges CTE scan to a handful of partitions. A row-less stub
|
||||
// root (purged/no-action parent) has no MIN — leave the scan unbounded in that
|
||||
// degenerate case (correctness over speed).
|
||||
DateTime? rootFirstOccurred;
|
||||
await using (var winCmd = conn.CreateCommand())
|
||||
{
|
||||
// --- 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);
|
||||
winCmd.CommandText =
|
||||
"SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
|
||||
var pWinRoot = winCmd.CreateParameter();
|
||||
pWinRoot.ParameterName = "@root";
|
||||
pWinRoot.Value = rootExecutionId;
|
||||
winCmd.Parameters.Add(pWinRoot);
|
||||
|
||||
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
parent = result is null or DBNull ? null : (Guid)result;
|
||||
}
|
||||
var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return new ChainAnchor(rootExecutionId, rootFirstOccurred);
|
||||
}
|
||||
|
||||
rootExecutionId = parent.Value;
|
||||
}
|
||||
|
||||
// --- Between phases: bound the down-walk to a root-anchored window ---
|
||||
// The root's first event timestamp anchors a [rootFirst - slack, rootFirst + maxSpan)
|
||||
// window used to prune the Edges CTE scan to a handful of partitions. A row-less stub
|
||||
// root (purged/no-action parent) has no MIN — leave the scan unbounded in that
|
||||
// degenerate case (correctness over speed).
|
||||
DateTime? rootFirstOccurred;
|
||||
await using (var winCmd = conn.CreateCommand())
|
||||
{
|
||||
winCmd.CommandText =
|
||||
"SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
|
||||
var pWinRoot = winCmd.CreateParameter();
|
||||
pWinRoot.ParameterName = "@root";
|
||||
pWinRoot.Value = rootExecutionId;
|
||||
winCmd.Parameters.Add(pWinRoot);
|
||||
|
||||
var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
|
||||
}
|
||||
|
||||
// Build the optional window predicate for the Edges CTE. When the root has rows the
|
||||
// scan is confined to the window (partition elimination); otherwise it stays unbounded.
|
||||
var edgesWindowPredicate = rootFirstOccurred is null
|
||||
? string.Empty
|
||||
: "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
|
||||
|
||||
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
||||
// edge set distilled from AuditLog. Recursing over edges
|
||||
// instead of raw rows means an execution with N audit rows
|
||||
// contributes ONE recursion path, not N — MAXRECURSION
|
||||
// bounds depth, not per-level width, so the raw-row form
|
||||
// could fan out badly. One edge per execution because all
|
||||
// rows of an execution share a single ParentExecutionId
|
||||
// (see the MIN(...) note on the final projection).
|
||||
// Chain : seeded at the root edge, recursively joins each edge whose
|
||||
// ParentExecutionId is an ExecutionId already in the chain.
|
||||
// Each edge carries its own ParentExecutionId, so the chain
|
||||
// of edges already surfaces every execution id in the tree
|
||||
// — including a row-less stub parent, which appears as the
|
||||
// ParentExecutionId of its child's edge. No separate
|
||||
// union-back CTE is needed.
|
||||
// Final : collect every distinct execution id reachable from the
|
||||
// chain (each edge's ExecutionId plus its non-null
|
||||
// ParentExecutionId), LEFT JOIN back to AuditLog and
|
||||
// GROUP BY so a stub parent — which owns no edge of its own
|
||||
// because it emitted no rows — still surfaces as a node with
|
||||
// RowCount 0 and NULL aggregates.
|
||||
var nodes = new List<ExecutionTreeNode>();
|
||||
await using (var downCmd = conn.CreateCommand())
|
||||
{
|
||||
downCmd.CommandText = $@"
|
||||
/// <summary>
|
||||
/// Phase 2's Edges/Chain CTE prefix — identical in both walkers (arch-review F9). Callers
|
||||
/// append their own projection: <see cref="GetExecutionTreeAsync"/> the <c>ChainIds</c> CTE
|
||||
/// plus the per-execution aggregate, <see cref="GetTraceEventsAsync"/> the bare id union.
|
||||
/// Both must terminate with <c>OPTION (MAXRECURSION …)</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
||||
/// edge set distilled from AuditLog. Recursing over edges
|
||||
/// instead of raw rows means an execution with N audit rows
|
||||
/// contributes ONE recursion path, not N — MAXRECURSION
|
||||
/// bounds depth, not per-level width, so the raw-row form
|
||||
/// could fan out badly. One edge per execution because all
|
||||
/// rows of an execution share a single ParentExecutionId
|
||||
/// (see the MIN(...) note on the tree walker's projection).
|
||||
/// Chain : seeded at the root edge, recursively joins each edge whose
|
||||
/// ParentExecutionId is an ExecutionId already in the chain.
|
||||
/// Each edge carries its own ParentExecutionId, so the chain
|
||||
/// of edges already surfaces every execution id in the tree
|
||||
/// — including a row-less stub parent, which appears as the
|
||||
/// ParentExecutionId of its child's edge. No separate
|
||||
/// union-back CTE is needed.
|
||||
/// </remarks>
|
||||
private static string ChainCtePrefix(string edgesWindowPredicate) => $@"
|
||||
WITH Edges AS (
|
||||
SELECT DISTINCT ExecutionId, ParentExecutionId
|
||||
FROM dbo.AuditLog
|
||||
@@ -1035,7 +1045,65 @@ VALUES
|
||||
SELECT e.ExecutionId, e.ParentExecutionId
|
||||
FROM Edges e
|
||||
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
|
||||
),
|
||||
)";
|
||||
|
||||
/// <summary>
|
||||
/// Binds the parameters the <see cref="ChainCtePrefix"/> statement needs: <c>@root</c> always,
|
||||
/// and the window bounds only when the anchor carries a root timestamp (matching
|
||||
/// <see cref="ChainAnchor.EdgesWindowPredicate"/>).
|
||||
/// </summary>
|
||||
private static void AddChainParameters(DbCommand cmd, ChainAnchor anchor)
|
||||
{
|
||||
var pRoot = cmd.CreateParameter();
|
||||
pRoot.ParameterName = "@root";
|
||||
pRoot.Value = anchor.RootExecutionId;
|
||||
cmd.Parameters.Add(pRoot);
|
||||
|
||||
if (anchor.RootFirstOccurred is { } rootFirst)
|
||||
{
|
||||
var pWinStart = cmd.CreateParameter();
|
||||
pWinStart.ParameterName = "@winStart";
|
||||
pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
|
||||
cmd.Parameters.Add(pWinStart);
|
||||
|
||||
var pWinEnd = cmd.CreateParameter();
|
||||
pWinEnd.ParameterName = "@winEnd";
|
||||
pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
|
||||
cmd.Parameters.Add(pWinEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 + window: shared with GetTraceEventsAsync (arch-review F9) ---
|
||||
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
|
||||
|
||||
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||
// The Edges/Chain prefix is ChainCtePrefix (see its remarks for the
|
||||
// DISTINCT-edges trick); this walker appends:
|
||||
// ChainIds: collect every distinct execution id reachable from the
|
||||
// chain (each edge's ExecutionId plus its non-null
|
||||
// ParentExecutionId), LEFT JOIN back to AuditLog and
|
||||
// GROUP BY so a stub parent — which owns no edge of its own
|
||||
// because it emitted no rows — still surfaces as a node with
|
||||
// RowCount 0 and NULL aggregates.
|
||||
var nodes = new List<ExecutionTreeNode>();
|
||||
await using (var downCmd = conn.CreateCommand())
|
||||
{
|
||||
downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@",
|
||||
ChainIds AS (
|
||||
SELECT ExecutionId FROM Chain
|
||||
UNION
|
||||
@@ -1071,23 +1139,7 @@ VALUES
|
||||
GROUP BY ids.ExecutionId
|
||||
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
|
||||
|
||||
var pRoot = downCmd.CreateParameter();
|
||||
pRoot.ParameterName = "@root";
|
||||
pRoot.Value = rootExecutionId;
|
||||
downCmd.Parameters.Add(pRoot);
|
||||
|
||||
if (rootFirstOccurred is { } rootFirst)
|
||||
{
|
||||
var pWinStart = downCmd.CreateParameter();
|
||||
pWinStart.ParameterName = "@winStart";
|
||||
pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
|
||||
downCmd.Parameters.Add(pWinStart);
|
||||
|
||||
var pWinEnd = downCmd.CreateParameter();
|
||||
pWinEnd.ParameterName = "@winEnd";
|
||||
pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
|
||||
downCmd.Parameters.Add(pWinEnd);
|
||||
}
|
||||
AddChainParameters(downCmd, anchor);
|
||||
|
||||
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
@@ -1147,77 +1199,18 @@ VALUES
|
||||
var chainIds = new List<Guid>();
|
||||
try
|
||||
{
|
||||
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) break;
|
||||
rootExecutionId = parent.Value;
|
||||
}
|
||||
|
||||
DateTime? rootFirstOccurred;
|
||||
await using (var winCmd = conn.CreateCommand())
|
||||
{
|
||||
winCmd.CommandText = "SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
|
||||
var pWinRoot = winCmd.CreateParameter();
|
||||
pWinRoot.ParameterName = "@root";
|
||||
pWinRoot.Value = rootExecutionId;
|
||||
winCmd.Parameters.Add(pWinRoot);
|
||||
var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
|
||||
}
|
||||
var edgesWindowPredicate = rootFirstOccurred is null
|
||||
? string.Empty
|
||||
: "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd";
|
||||
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
|
||||
|
||||
await using (var idsCmd = conn.CreateCommand())
|
||||
{
|
||||
idsCmd.CommandText = $@"
|
||||
WITH Edges AS (
|
||||
SELECT DISTINCT ExecutionId, ParentExecutionId
|
||||
FROM dbo.AuditLog
|
||||
WHERE ExecutionId IS NOT NULL
|
||||
{edgesWindowPredicate}
|
||||
),
|
||||
Chain AS (
|
||||
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
|
||||
CAST(NULL AS uniqueidentifier) AS ParentExecutionId
|
||||
UNION ALL
|
||||
SELECT e.ExecutionId, e.ParentExecutionId
|
||||
FROM Edges e
|
||||
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
|
||||
)
|
||||
idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@"
|
||||
SELECT ExecutionId FROM Chain
|
||||
UNION
|
||||
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL
|
||||
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
|
||||
var pRoot = idsCmd.CreateParameter();
|
||||
pRoot.ParameterName = "@root";
|
||||
pRoot.Value = rootExecutionId;
|
||||
idsCmd.Parameters.Add(pRoot);
|
||||
if (rootFirstOccurred is { } rootFirst)
|
||||
{
|
||||
var pWinStart = idsCmd.CreateParameter();
|
||||
pWinStart.ParameterName = "@winStart";
|
||||
pWinStart.Value = rootFirst - ExecutionTreeWindowSlack;
|
||||
idsCmd.Parameters.Add(pWinStart);
|
||||
var pWinEnd = idsCmd.CreateParameter();
|
||||
pWinEnd.ParameterName = "@winEnd";
|
||||
pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan;
|
||||
idsCmd.Parameters.Add(pWinEnd);
|
||||
}
|
||||
|
||||
AddChainParameters(idsCmd, anchor);
|
||||
|
||||
await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user