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
@@ -168,8 +168,8 @@ public interface IAuditLogRepository
/// <para>
/// <b>Bounded + idempotent.</b> Each batch is a <c>DELETE TOP (@batch)</c> so the
/// transaction log and lock footprint stay bounded regardless of backlog. Re-running
/// the purge is a no-op once every eligible row is gone (the loop exits when a batch
/// deletes zero rows), so a crash mid-loop is recoverable by simply running again.
/// the purge resumes remaining backlog after reaching <paramref name="maxRows"/>;
/// an empty batch ends it sooner. A crash mid-loop is recoverable by running again.
/// </para>
/// </remarks>
/// <param name="channel">Canonical channel name (the <c>Category</c> column value, e.g. <c>ApiOutbound</c>).</param>
@@ -183,13 +183,14 @@ public interface IAuditLogRepository
/// by the ~30s ADO.NET default (arch-review 04, S2).
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <param name="maxRows">Maximum total rows deleted in this call; remaining backlog is retained for a later tick.</param>
/// <returns>A task that resolves to the total number of rows deleted across all batches.</returns>
Task<long> PurgeChannelOlderThanAsync(
string channel,
DateTime threshold,
int batchSize,
TimeSpan? commandTimeout = null,
CancellationToken ct = default);
CancellationToken ct = default, int maxRows = 50_000);
/// <summary>
/// Returns the set of <c>pf_AuditLog_Month</c> partition lower-bound
@@ -250,6 +250,10 @@ public interface ITemplateEngineRepository
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of instances for the specified site.</returns>
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
/// <summary>Reads one bounded, untracked page of topology instance rows for a site/area.</summary>
Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default);
/// <summary>Retrieves an instance by unique name.</summary>
/// <param name="uniqueName">The unique instance name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -17,11 +17,12 @@ public interface IPullTraceEventsClient
/// <param name="sinceUtc">Lower bound on <c>OccurredAtUtc</c> (the run's start is the natural choice).</param>
/// <param name="maxEvents">Row cap (the site caps at 2000).</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="afterEventId">Optional exclusive EventId at sinceUtc for stable keyset paging.</param>
/// <returns>The matching rows ordered <c>(OccurredAtUtc, EventId)</c>; empty on fault or unknown site.</returns>
Task<IReadOnlyList<AuditEvent>> PullAsync(
string siteId,
IReadOnlyCollection<Guid> executionIds,
DateTime sinceUtc,
int maxEvents,
CancellationToken ct);
CancellationToken ct, string? afterEventId = null);
}
@@ -232,5 +232,5 @@ public interface ISiteAuditQueue
/// forwarding state — moves no cursor and flips nothing. At most 64 ids per call.
/// </summary>
Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default);
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null);
}
@@ -12,13 +12,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
/// <param name="Quality">Quality of the new value (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>).</param>
/// <param name="Timestamp">UTC timestamp of the change.</param>
/// <param name="SourceExecutionId">
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
/// script execution / inbound API request whose write produced this change, or
/// <c>null</c> when the change did not originate from an audited execution —
/// which is the case for every value that arrives from the Data Connection
/// Layer (external device data), for deployment-time seeding, and for the
/// confirmed value of a data-sourced write (the device echo arrives on the
/// subscription long after the writing execution ended).
/// Execution associated with this change. Static writes carry an observed writer id;
/// armed device updates carry only a candidate writer id (see
/// <paramref name="SourceExecutionInferred"/>), since subscriptions carry no write-request id.
/// Other device updates and deployment seeding carry no id.
///
/// <para>
/// The Alarm Actor records this id as the <c>ParentExecutionId</c> of the
@@ -34,6 +31,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
/// </para>
/// </param>
/// <param name="Trace">Flow Debugger trace flag — see design 2026-09-05 §5.2. Additive; defaults to untraced.</param>
/// <param name="SourceExecutionInferred">True when the source is inferred from the first device update within the trace-arm window, regardless of value equality, clamping, or coercion; it is not evidence of a write echo.</param>
public record AttributeValueChanged(
string InstanceUniqueName,
string AttributePath,
@@ -42,4 +40,5 @@ public record AttributeValueChanged(
string Quality,
DateTimeOffset Timestamp,
Guid? SourceExecutionId = null,
bool Trace = false) : ISiteStreamEvent;
bool Trace = false,
bool SourceExecutionInferred = false) : ISiteStreamEvent;
@@ -66,16 +66,17 @@ public static class FlowGraphBuilder
// D2: a ScriptLog row is a line, never a node. Partition BEFORE the input-order index below, so
// `seq` indexes only real step rows and a line can never shift an execution's display position.
var lines = new List<FlowLogLine>();
var stepEvents = new List<AuditEvent>(events.Count);
var stepEvents = new List<(AuditEvent Event, AuditDetails Details)>(events.Count);
foreach (var e in events)
{
if (FlowStepProjector.IsLogLine(e))
lines.Add(FlowStepProjector.ToLogLine(e));
var details = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (FlowStepProjector.IsLogLine(details))
lines.Add(FlowStepProjector.ToLogLine(e, details));
else
stepEvents.Add(e);
stepEvents.Add((e, details));
}
events = stepEvents;
events = stepEvents.Select(e => e.Event).ToList();
if (events.Count == 0)
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated,
@@ -105,7 +106,7 @@ public static class FlowGraphBuilder
return index;
}
var folded = Fold(events).ToList();
var folded = Fold(stepEvents).ToList();
var firstSeqByExecution = folded
.GroupBy(s => s.ExecutionId ?? Guid.Empty)
.ToDictionary(g => g.Key, g => g.Min(InputIndex));
@@ -205,7 +206,7 @@ public static class FlowGraphBuilder
var firstChained = chain.FirstOrDefault(s => !IsAsync(s));
if (firstChained is not null && spawner.EventId != firstChained.EventId)
edges.Add(new FlowEdge(spawner.EventId, firstChained.EventId, CrossesBoundary(spawner, firstChained), External: false, Spawn: spawn));
edges.Add(new FlowEdge(spawner.EventId, firstChained.EventId, CrossesBoundary(spawner, firstChained), External: spawn && first.Extra?.TriggerSource == "inferred-device-update", Spawn: spawn));
}
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
@@ -219,6 +220,7 @@ public static class FlowGraphBuilder
for (var j = i - 1; j >= 0; j--)
{
if (chain[j].Kind != FlowStepKind.AttributeWrite) continue;
edges.RemoveAll(e => e.FromEventId == chain[j].EventId && e.ToEventId == wait.EventId);
edges.Add(new FlowEdge(chain[j].EventId, wait.EventId, CrossesBoundary: false, External: true));
break;
}
@@ -376,14 +378,14 @@ public static class FlowGraphBuilder
/// Projects every event and folds the outbound rows of one operation (same kind + <c>CorrelationId</c>) into one step
/// carrying the whole lifecycle. Non-outbound rows and null-correlation outbound rows pass through one-to-one.
/// </summary>
private static IEnumerable<FlowStep> Fold(IReadOnlyList<AuditEvent> events)
private static IEnumerable<FlowStep> Fold(IReadOnlyList<(AuditEvent Event, AuditDetails Details)> events)
{
var groups = new Dictionary<(FlowStepKind Kind, Guid Correlation), List<(FlowStep Step, AuditEvent Evt)>>();
var groups = new Dictionary<(FlowStepKind Kind, Guid Correlation), List<(FlowStep Step, FlowLifecycleRow Row)>>();
var order = new List<object>(events.Count); // FlowStep, or the group key — preserves first-seen order
foreach (var evt in events)
foreach (var (evt, details) in events)
{
var step = FlowStepProjector.Project(evt);
var step = FlowStepProjector.Project(evt, details);
if (!FlowStepProjector.IsOutbound(step.Kind))
{
order.Add(step);
@@ -392,7 +394,7 @@ public static class FlowGraphBuilder
if (evt.CorrelationId is not { } correlation)
{
order.Add(step with { Lifecycle = [FlowStepProjector.ToLifecycleRow(evt)] });
order.Add(step with { Lifecycle = [FlowStepProjector.ToLifecycleRow(evt, details)] });
continue;
}
@@ -403,7 +405,7 @@ public static class FlowGraphBuilder
groups[key] = group;
order.Add(key);
}
group.Add((step, evt));
group.Add((step, FlowStepProjector.ToLifecycleRow(evt, details)));
}
foreach (var item in order)
@@ -416,7 +418,6 @@ public static class FlowGraphBuilder
var key = ((FlowStepKind, Guid))item;
var rows = groups[key]
.Select(r => (r.Step, Row: FlowStepProjector.ToLifecycleRow(r.Evt)))
.OrderBy(r => r.Row.OccurredAtUtc)
.ThenBy(r => LifecycleRank(r.Row.Kind, r.Row.Status))
.ThenBy(r => r.Row.EventId)
@@ -435,6 +436,7 @@ public static class FlowGraphBuilder
Subtitle = latest.Subtitle,
OutputJson = latest.OutputJson,
ErrorMessage = latest.ErrorMessage,
ErrorDetail = latest.ErrorDetail,
DurationMs = rows.Count == 1 && !terminal ? null : duration,
Lifecycle = lifecycle,
};
@@ -65,6 +65,7 @@ public enum FlowStepOutcome
/// <param name="Node">Node that wrote it.</param>
/// <param name="ErrorMessage">Error summary on a failed attempt.</param>
/// <param name="DurationMs">The row's own duration, when it has one.</param>
/// <param name="ErrorDetail">Exception detail from this lifecycle row.</param>
public sealed record FlowLifecycleRow(
Guid EventId,
string Kind,
@@ -72,7 +73,8 @@ public sealed record FlowLifecycleRow(
DateTimeOffset OccurredAtUtc,
string? Node,
string? ErrorMessage,
int? DurationMs);
int? DurationMs,
string? ErrorDetail = null);
/// <summary>
/// One log line folded onto a step (<see cref="FlowStep.Logs"/>; design 2026-09-09 D1/D8) — the
@@ -17,7 +17,11 @@ public static class FlowStepProjector
public static FlowStep Project(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
return Project(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
}
internal static FlowStep Project(AuditEvent evt, AuditDetails d)
{
var extra = ScriptTraceExtra.TryParse(d.Extra);
var kind = ResolveKind(d.Channel, d.Kind);
var lane = ResolveLane(kind, d.SourceSiteId);
@@ -52,6 +56,11 @@ public static class FlowStepProjector
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
return IsLogLine(d);
}
internal static bool IsLogLine(AuditDetails d)
{
return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)
&& string.Equals(d.Kind, nameof(AuditKind.ScriptLog), StringComparison.Ordinal);
}
@@ -60,7 +69,11 @@ public static class FlowStepProjector
public static FlowLogLine ToLogLine(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
return ToLogLine(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
}
internal static FlowLogLine ToLogLine(AuditEvent evt, AuditDetails d)
{
var x = ScriptTraceExtra.TryParse(d.Extra);
return new FlowLogLine(
EventId: evt.EventId,
@@ -79,7 +92,11 @@ public static class FlowStepProjector
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
return ToLifecycleRow(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
}
internal static FlowLifecycleRow ToLifecycleRow(AuditEvent evt, AuditDetails d)
{
return new FlowLifecycleRow(
EventId: evt.EventId,
Kind: d.Kind ?? string.Empty,
@@ -87,7 +104,8 @@ public static class FlowStepProjector
OccurredAtUtc: evt.OccurredAtUtc,
Node: evt.SourceNode,
ErrorMessage: d.ErrorMessage,
DurationMs: d.DurationMs);
DurationMs: d.DurationMs,
ErrorDetail: d.ErrorDetail);
}
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
@@ -22,7 +22,7 @@ public sealed record ScriptTraceExtra
/// <summary>Encoded value that fired a value-change run.</summary>
public string? TriggerValue { get; init; }
/// <summary>Where the triggering change came from: <c>value-change</c> (the actor cannot tell a static write from a device echo).</summary>
/// <summary>Where the triggering change came from: <c>value-change</c> for an observed write, or <c>inferred-device-update</c> for the first device publish in a trace-arm window (not a confirmed echo).</summary>
public string? TriggerSource { get; init; }
/// <summary>On a <c>RouteCall</c> row: the routed site run's <c>ExecutionId</c>.</summary>