Resolve all 46 low-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 22:56:36 -04:00
parent fef3e7675f
commit 9ee402bbcd
139 changed files with 2621 additions and 1082 deletions
@@ -60,7 +60,10 @@ public sealed class FlowDebugRunner
/// loop running on the node after the caller's Ask has already expired, holding its scoped
/// provider and pulling from the site for a result nobody can receive.
/// </summary>
public const int MaxWaitSeconds = 300;
public const int MaxWaitSeconds = 295;
/// <summary>Time reserved for serializing and returning a management reply.</summary>
public static readonly TimeSpan ResponseGrace = TimeSpan.FromSeconds(5);
/// <summary>
/// Arch-review F2: <see cref="InboundScriptExecutor"/>'s code for a routed call refused
@@ -83,22 +86,8 @@ public sealed class FlowDebugRunner
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
/// <summary>
/// Row cap asked of the site on each <see cref="IPullTraceEventsClient.PullAsync"/> poll — the
/// site's own ceiling (<c>SiteStreamGrpcServer.MaxTraceEventsPerPull</c> = 2000; a larger
/// <c>max_events</c> is rejected <c>InvalidArgument</c>), so this asks for everything the site
/// will serve.
///
/// <para>
/// Final review I1: this was 500, which was ample before step logs but is not any more. The
/// site query is <c>ORDER BY OccurredAtUtc ASC LIMIT</c>, so the page is the OLDEST rows in
/// the window — and a single traced run now emits up to 201 <c>ScriptLog</c> rows (design
/// §3, <c>MaxLogLinesPerRun</c> + the closing dropped-count row). Two chatty runs could
/// therefore fill a 500-row page and push a later spawned run's <c>ScriptRun</c> row past the
/// end of it, making <see cref="WriteAndPollAsync"/> report a false
/// <see cref="NoTriggerCode"/> for a run that did happen. 2000 makes that ~10x harder; the
/// real fix (paging by last-seen timestamp, or excluding <c>ScriptLog</c> from the awaited-run
/// poll) is deferred-work register row 117.
/// </para>
/// Site page ceiling. Full pages are drained with an exclusive timestamp/event-id cursor;
/// execution IDs are split into independent batches of at most 64.
/// </summary>
public const int PullTraceEventsMax = 2000;
@@ -136,8 +125,12 @@ public sealed class FlowDebugRunner
/// registered none (a test composition root) collects raw but is still capped.
/// </para>
/// </summary>
public async Task<FlowDebugRunResult> RunMethodAsync(
public Task<FlowDebugRunResult> RunMethodAsync(
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, CancellationToken ct)
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunMethodCoreAsync(sp, cmd, user, deadline, token), ct);
private async Task<FlowDebugRunResult> RunMethodCoreAsync(
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
@@ -240,8 +233,12 @@ public sealed class FlowDebugRunner
/// Live run of a value-change trigger (design §5.3/§5.4): traced routed write through the
/// site's normal path, then poll the site's trace rows for the spawned run(s).
/// </summary>
public async Task<FlowDebugRunResult> RunValueChangeAsync(
public Task<FlowDebugRunResult> RunValueChangeAsync(
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, CancellationToken ct)
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunValueChangeCoreAsync(sp, cmd, user, deadline, token), ct);
private async Task<FlowDebugRunResult> RunValueChangeCoreAsync(
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
@@ -259,7 +256,7 @@ public sealed class FlowDebugRunner
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
return await WriteAndPollAsync(
sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, ct)
sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, commandDeadline, ct)
.ConfigureAwait(false);
}
@@ -272,8 +269,12 @@ public sealed class FlowDebugRunner
/// rather than through <see cref="RouteHelper"/> is deliberate: the helper emits its own
/// always-on RouteCall row with no operator actor, which would give the graph two roots.
/// </summary>
public async Task<FlowDebugRunResult> RunScriptAsync(
public Task<FlowDebugRunResult> RunScriptAsync(
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct)
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunScriptCoreAsync(sp, cmd, user, deadline, token), ct);
private async Task<FlowDebugRunResult> RunScriptCoreAsync(
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
ArgumentNullException.ThrowIfNull(cmd);
@@ -312,7 +313,7 @@ public sealed class FlowDebugRunner
if (cmd.Value is null)
throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode);
return await WriteAndPollAsync(
sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false);
sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, commandDeadline, ct).ConfigureAwait(false);
}
var resolveRef = await SchemaRefResolver.BuildAsync(
@@ -431,7 +432,7 @@ public sealed class FlowDebugRunner
/// </summary>
private async Task<FlowDebugRunResult> WriteAndPollAsync(
IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site,
string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct)
string attributeName, string? value, int waitSeconds, string? triggerScript, DateTimeOffset commandDeadline, CancellationToken ct)
{
var root = Guid.NewGuid();
var startedAt = DateTimeOffset.UtcNow;
@@ -496,22 +497,53 @@ public sealed class FlowDebugRunner
var puller = sp.GetRequiredService<IPullTraceEventsClient>();
var wait = Math.Clamp(waitSeconds, 1, MaxWaitSeconds);
var deadline = _now() + TimeSpan.FromSeconds(wait);
if (deadline > commandDeadline) deadline = commandDeadline;
var known = new Dictionary<Guid, AuditEvent>();
DateTimeOffset? lastNew = null;
var since = startedAt.UtcDateTime.AddSeconds(-5);
// Root is a fresh GUID for this command, so matching it and its discovered descendants
// requires no cross-node timestamp lower bound. Site clocks may be behind central.
var since = DateTime.MinValue;
// Stable ID batches avoid silently dropping descendants beyond the per-read ID cap.
var assigned = new HashSet<Guid>();
var batches = new List<TraceReadBatch>();
while (!ct.IsCancellationRequested)
{
var ids = new HashSet<Guid> { root };
var discovered = new HashSet<Guid> { root };
foreach (var e in known.Values)
{
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.ExecutionId is { } x) ids.Add(x);
if (d.ExecutionId is { } x) discovered.Add(x);
}
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, PullTraceEventsMax, ct).ConfigureAwait(false);
foreach (var ids in discovered.Where(id => assigned.Add(id)).Chunk(64))
batches.Add(new TraceReadBatch(ids, since));
var added = false;
foreach (var e in pulled)
foreach (var batch in batches)
{
if (known.TryAdd(e.EventId, e)) added = true;
// ScriptRun/operation closing rows are inserted at completion but stamped with
// their start time, potentially behind a cursor that already saw live log rows.
// Drain every page within this scan, then intentionally rescan on the next poll.
// This trades repeated transfer for correct late/backdated-row discovery.
batch.SinceUtc = since;
batch.AfterEventId = null;
while (!ct.IsCancellationRequested && _now() < deadline)
{
var pulled = await puller.PullAsync(site.SiteIdentifier, batch.ExecutionIds,
batch.SinceUtc, PullTraceEventsMax, ct, batch.AfterEventId).ConfigureAwait(false);
foreach (var e in pulled)
if (known.TryAdd(e.EventId, e)) added = true;
if (pulled.Count == 0) break;
var last = pulled[^1];
var nextTime = last.OccurredAtUtc.UtcDateTime;
var nextId = last.EventId.ToString("D");
// An old peer ignoring the additive cursor must not cause a tight repeat loop.
var advanced = nextTime > batch.SinceUtc || nextTime == batch.SinceUtc
&& (batch.AfterEventId is null || string.CompareOrdinal(nextId, batch.AfterEventId) > 0);
if (!advanced) break;
batch.SinceUtc = nextTime;
batch.AfterEventId = nextId;
if (pulled.Count < PullTraceEventsMax) break;
}
}
if (added) lastNew = _now();
@@ -522,8 +554,11 @@ public sealed class FlowDebugRunner
await _delay(PollInterval, ct).ConfigureAwait(false);
}
var ordered = new List<AuditEvent> { redactedRoot };
ordered.AddRange(known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
var collector = new FlowTraceCollector();
collector.Add(redactedRoot);
foreach (var evt in known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId))
collector.Add(evt, lowPriority: FlowStepProjector.IsLogLine(evt));
var ordered = collector.Complete();
var completed = known.Values.Any(IsAwaitedRun);
return new FlowDebugRunResult(
root, completed,
@@ -531,7 +566,7 @@ public sealed class FlowDebugRunner
? $"No traced run was spawned within {wait}s (no value-change trigger on '{attributeName}', or no device echo)."
: $"No traced run of '{triggerScript}' was spawned within {wait}s after writing '{attributeName}' (trigger condition not met, or no device echo).",
completed ? null : NoTriggerCode,
ordered, false, startedAt, DateTimeOffset.UtcNow);
ordered, collector.Truncated, startedAt, DateTimeOffset.UtcNow, LogsTruncated: collector.LogsTruncated);
}
/// <summary>Replay (design §7.1): the stored ScriptTrace chain for any execution id.</summary>
@@ -590,6 +625,34 @@ public sealed class FlowDebugRunner
}
}
private sealed class TraceReadBatch(Guid[] executionIds, DateTime sinceUtc)
{
public Guid[] ExecutionIds { get; } = executionIds;
public DateTime SinceUtc { get; set; } = sinceUtc;
public string? AfterEventId { get; set; }
}
private async Task<FlowDebugRunResult> WithinCommandBudgetAsync(
IServiceProvider sp, Func<DateTimeOffset, CancellationToken, Task<FlowDebugRunResult>> run, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(sp);
var timeout = sp.GetService<Microsoft.Extensions.Options.IOptions<ManagementServiceOptions>>()?.Value.LongRunningCommandTimeout
?? TimeSpan.FromMinutes(5);
var budget = timeout - ResponseGrace;
if (budget <= TimeSpan.Zero)
throw new ManagementCommandException("Long-running command timeout must exceed the response grace.", ValidationFailedCode);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(budget);
try
{
return await run(_now() + budget, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
{
throw new ManagementCommandException("Flow Debugger command exceeded its total execution budget.", TimeoutCode);
}
}
private static void EnsureActiveNode(IServiceProvider sp)
{
var gate = sp.GetService<IActiveNodeGate>();
@@ -651,6 +714,10 @@ public sealed class FlowDebugRunner
{
await writer.WriteAsync(evt, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Flow Debugger central audit write failed for {Action}", evt.Action);