Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunSummaryRecorder.cs
T
Joseph Doherty 0ffd4bdbea docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
2026-10-07 14:12:25 -04:00

230 lines
10 KiB
C#

using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Site_events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
/// accumulates per-(instance, script) run counters between flushes and emits ONE aggregate
/// "script" Info site event per interval instead of the two per-run Started/Completed rows
/// that were the dominant <c>site_events</c> writer. Registered as a process-wide singleton
/// (<c>ServiceCollectionExtensions.AddSiteRuntime</c>); incremented from the same
/// <see cref="ScriptRunLauncher"/> call sites that used to emit the per-run events, flushed
/// on a timer by <c>ScriptRunSummaryFlushService</c>.
///
/// <para>Lock-free: each script's counters are a small class of <c>long</c> fields mutated
/// via <see cref="Interlocked"/>, reached through a <see cref="ConcurrentDictionary{TKey,TValue}"/>
/// that <see cref="FlushAsync"/> atomically swaps out (<see cref="Interlocked.Exchange"/>), so
/// the flush never blocks a recording call (or vice versa).</para>
///
/// <para>Accounting is BEST-EFFORT, not exact. A recorder that has already resolved its
/// <see cref="Counters"/> object from the pre-swap dictionary can increment it after the swap
/// has snapshotted it, so that increment lands in a window that has already been summarised
/// and is never reported. These are operational volume counters for one interval row, not a
/// ledger: an occasional count landing in neither window is acceptable, and nothing
/// downstream reconciles totals across intervals. What IS guaranteed is that a count is never
/// double-reported (each <see cref="Counters"/> instance is summarised by exactly one flush)
/// and that runs spanning a flush boundary — started in window N, completed in N+1 — still
/// emit their completion, because the flush filter admits a completion-only entry.</para>
/// </summary>
public sealed class ScriptRunSummaryRecorder
{
/// <summary>
/// Per-script breakdown cap in the flushed details JSON — the rest fold into a single
/// "others" rollup entry so a deployment with hundreds of scripts cannot mint an
/// oversized site event row.
/// </summary>
internal const int TopScriptCap = 50;
private sealed class Counters
{
public long Started;
public long Completed;
public long Failed;
public long TimedOut;
public long TotalDurationMs;
public long MaxDurationMs;
}
private ConcurrentDictionary<(string InstanceName, string ScriptName), Counters> _counters = new();
private Counters GetOrAddCounters(string instanceName, string scriptName) =>
Volatile.Read(ref _counters).GetOrAdd((instanceName, scriptName), static _ => new Counters());
/// <summary>Records that a script run began executing (past the shed-at-dequeue check).</summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that ran.</param>
public void RecordStarted(string instanceName, string scriptName)
{
var counters = GetOrAddCounters(instanceName, scriptName);
Interlocked.Increment(ref counters.Started);
}
/// <summary>Records that a script run completed successfully.</summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that ran.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public void RecordCompleted(string instanceName, string scriptName, long durationMs)
{
var counters = GetOrAddCounters(instanceName, scriptName);
Interlocked.Increment(ref counters.Completed);
RecordDuration(counters, durationMs);
}
/// <summary>Records that a script run threw an unhandled exception.</summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that ran.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public void RecordFailed(string instanceName, string scriptName, long durationMs)
{
var counters = GetOrAddCounters(instanceName, scriptName);
Interlocked.Increment(ref counters.Failed);
RecordDuration(counters, durationMs);
}
/// <summary>
/// Records that a script run timed out — including a run shed at dequeue (its deadline
/// was already spent queueing, so it never reached <see cref="RecordStarted"/>).
/// </summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that timed out.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public void RecordTimedOut(string instanceName, string scriptName, long durationMs)
{
var counters = GetOrAddCounters(instanceName, scriptName);
Interlocked.Increment(ref counters.TimedOut);
RecordDuration(counters, durationMs);
}
private static void RecordDuration(Counters counters, long durationMs)
{
if (durationMs < 0) durationMs = 0;
Interlocked.Add(ref counters.TotalDurationMs, durationMs);
InterlockedMax(ref counters.MaxDurationMs, durationMs);
}
/// <summary>Lock-free running-max update (no built-in <c>Interlocked.Max(ref long, long)</c>).</summary>
private static void InterlockedMax(ref long location, long candidate)
{
var initial = Volatile.Read(ref location);
while (candidate > initial)
{
var previous = Interlocked.CompareExchange(ref location, candidate, initial);
if (previous == initial) return;
initial = previous;
}
}
/// <summary>
/// Snapshots and resets the accumulated counters, then — if any script started, completed,
/// failed, or timed out during the interval — emits ONE aggregate "script" Info site event
/// summarizing the window. An interval with zero activity emits no row (the standby,
/// which never runs scripts, therefore never produces a summary row naturally).
/// </summary>
/// <param name="siteEventLogger">The site event logger to emit the aggregate row to.</param>
/// <returns><c>true</c> if a summary row was emitted; <c>false</c> if the interval was idle.</returns>
public async Task<bool> FlushAsync(ISiteEventLogger siteEventLogger)
{
ArgumentNullException.ThrowIfNull(siteEventLogger);
// The replacement dictionary's type argument must name its tuple elements
// IDENTICALLY to the field's declared type — Interlocked.Exchange<T> is generic
// over both the ref and value parameters, and mixing a named-tuple exact bound
// with an unnamed one erases the names from the inferred T (and therefore from
// `snapshot` below).
var snapshot = Interlocked.Exchange(
ref _counters,
new ConcurrentDictionary<(string InstanceName, string ScriptName), Counters>());
// Completed > 0 is admitted on its own: a run that started in window N and finished in
// N+1 records its completion against a FRESH counters object whose Started is 0, so
// filtering on Started/Failed/TimedOut alone would silently drop that completion (and,
// with it, the run's duration) from every summary row that could ever carry it.
var entries = snapshot
.Where(kvp => kvp.Value.Started > 0 || kvp.Value.Completed > 0 ||
kvp.Value.Failed > 0 || kvp.Value.TimedOut > 0)
.ToList();
if (entries.Count == 0)
return false;
long totalStarted = 0, totalCompleted = 0, totalFailed = 0, totalTimedOut = 0;
foreach (var entry in entries)
{
totalStarted += entry.Value.Started;
totalCompleted += entry.Value.Completed;
totalFailed += entry.Value.Failed;
totalTimedOut += entry.Value.TimedOut;
}
// Rank by total run activity. Started+TimedOut (not just Started): a run shed at
// dequeue never reaches RecordStarted, so ranking on Started alone would silently
// drop a script that does nothing BUT get shed from the breakdown entirely.
var ranked = entries
.OrderByDescending(e => e.Value.Started + e.Value.TimedOut)
.ToList();
var top = ranked.Take(TopScriptCap);
var rest = ranked.Skip(TopScriptCap).ToList();
var breakdown = top.Select(e => new ScriptSummaryEntry(
e.Key.InstanceName,
e.Key.ScriptName,
e.Value.Started,
e.Value.Completed,
e.Value.Failed,
e.Value.TimedOut,
e.Value.Completed > 0 ? e.Value.TotalDurationMs / e.Value.Completed : 0,
e.Value.MaxDurationMs)).ToList();
object details;
if (rest.Count > 0)
{
details = new
{
scripts = breakdown,
others = new
{
scriptCount = rest.Count,
started = rest.Sum(e => e.Value.Started),
completed = rest.Sum(e => e.Value.Completed),
failed = rest.Sum(e => e.Value.Failed),
timedOut = rest.Sum(e => e.Value.TimedOut),
}
};
}
else
{
details = new { scripts = breakdown };
}
var message = string.Format(
CultureInfo.InvariantCulture,
"{0:N0} runs: {1:N0} completed, {2:N0} failed, {3:N0} timed out across {4:N0} scripts",
totalStarted, totalCompleted, totalFailed, totalTimedOut, entries.Count);
var detailsJson = JsonSerializer.Serialize(details, DetailsJsonOptions);
await siteEventLogger.LogEventAsync(
"script", "Info", null, "ScriptRunSummary", message, detailsJson).ConfigureAwait(false);
return true;
}
// Built once per assembly rather than per flush.
private static readonly JsonSerializerOptions DetailsJsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private sealed record ScriptSummaryEntry(
string InstanceName,
string ScriptName,
long Started,
long Completed,
long Failed,
long TimedOut,
long AvgDurationMs,
long MaxDurationMs);
}