Resolve all 46 low-severity architecture review findings
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user