Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/ResourceSampler.cs
T
Joseph Doherty 20f6b0b969 test(loadharness): target-scale load harness for WP-4 / register #25 + row 50
Standalone console harness under tests/ZB.MOM.WW.ScadaBridge.LoadHarness plus a
scaled-down Category=Performance smoke [Fact] in PerformanceTests. Deliberately an
Exe rather than an xunit suite: the Performance trait enables a filter but does not
exclude by default, so a 20-minute test would run on every 'dotnet test' of the slnx.

What is real: per-site ActorSystem + LocalDb SQLite file, the real DCL
(DataConnectionManagerActor/DataConnectionActor over a SimulatedDataConnection
registered through the documented DataConnectionFactory.RegisterAdapter seam), real
InstanceActors fed real TagValueUpdates, the real SiteStreamManager, real
StreamRelayActor + production-capacity bounded DropOldest channel, real
StoreAndForwardService/Storage, real SiteHealthCollector + CentralHealthAggregator.
Only the socket hops are stood in for.

Measures: end-to-end tag update latency (the emit instant rides
TagValueUpdate.Timestamp verbatim to the subscriber), instance ramp, memory
growth/CPU over a steady-state window, health report and debug view latency under
load, S&F concurrent buffering + drain throughput, and slow-subscriber isolation.
2026-08-15 02:23:04 -04:00

203 lines
7.8 KiB
C#

using System.Diagnostics;
namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics;
/// <summary>
/// One periodic observation of process resource usage.
/// </summary>
/// <param name="ElapsedSeconds">Seconds since sampling started.</param>
/// <param name="WorkingSetMb">Process working set (RSS).</param>
/// <param name="ManagedHeapMb"><see cref="GC.GetTotalMemory(bool)"/> without forcing a collection.</param>
/// <param name="CpuPercent">Mean CPU utilization since the previous sample, as a percentage of ONE core.</param>
/// <param name="ThreadCount">OS threads in the process.</param>
/// <param name="Gen2Collections">Cumulative gen-2 collections.</param>
public sealed record ResourceSample(
double ElapsedSeconds,
double WorkingSetMb,
double ManagedHeapMb,
double CpuPercent,
int ThreadCount,
int Gen2Collections);
/// <summary>
/// Samples working set, managed heap, CPU and thread count on a fixed cadence for the
/// life of a run. CPU is differential (processor time delta / wall delta) so a sample
/// reflects the interval it covers rather than the whole process lifetime.
/// </summary>
public sealed class ResourceSampler : IAsyncDisposable
{
private readonly List<ResourceSample> _samples = new();
private readonly object _lock = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _loop;
private readonly Stopwatch _wall = Stopwatch.StartNew();
private TimeSpan _lastCpu;
private double _lastElapsedSeconds;
private ResourceSampler(TimeSpan interval)
{
_lastCpu = Process.GetCurrentProcess().TotalProcessorTime;
_loop = Task.Run(() => SampleLoopAsync(interval, _cts.Token));
}
/// <summary>Starts sampling at the given cadence.</summary>
/// <param name="interval">Sampling interval.</param>
/// <returns>The running sampler.</returns>
public static ResourceSampler Start(TimeSpan interval) => new(interval);
/// <summary>All samples collected so far, oldest first.</summary>
/// <returns>A snapshot copy of the sample list.</returns>
public IReadOnlyList<ResourceSample> Snapshot()
{
lock (_lock) return _samples.ToList();
}
private async Task SampleLoopAsync(TimeSpan interval, CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(interval);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
Capture();
}
catch (OperationCanceledException)
{
// Normal teardown.
}
}
private void Capture()
{
using var process = Process.GetCurrentProcess();
process.Refresh();
var elapsedSeconds = _wall.Elapsed.TotalSeconds;
var cpu = process.TotalProcessorTime;
var wallDelta = elapsedSeconds - _lastElapsedSeconds;
var cpuPercent = wallDelta > 0
? (cpu - _lastCpu).TotalSeconds / wallDelta * 100.0
: 0.0;
_lastCpu = cpu;
_lastElapsedSeconds = elapsedSeconds;
var sample = new ResourceSample(
elapsedSeconds,
process.WorkingSet64 / 1024.0 / 1024.0,
GC.GetTotalMemory(forceFullCollection: false) / 1024.0 / 1024.0,
cpuPercent,
process.Threads.Count,
GC.CollectionCount(2));
lock (_lock) _samples.Add(sample);
}
/// <summary>
/// Summarizes the samples falling inside a window, expressed as seconds since
/// sampling started. Memory growth is reported both as an absolute delta and as a
/// least-squares slope, because a run that sawtooths around a stable mean and a
/// run that climbs monotonically can share the same endpoint delta.
/// </summary>
/// <param name="fromSeconds">Window start (inclusive), seconds since start.</param>
/// <param name="toSeconds">Window end (inclusive), seconds since start.</param>
/// <returns>The window summary, or null when fewer than two samples fall inside it.</returns>
public ResourceWindowSummary? Summarize(double fromSeconds, double toSeconds)
{
var window = Snapshot()
.Where(s => s.ElapsedSeconds >= fromSeconds && s.ElapsedSeconds <= toSeconds)
.ToList();
if (window.Count < 2)
return null;
var first = window[0];
var last = window[^1];
return new ResourceWindowSummary(
SampleCount: window.Count,
DurationSeconds: last.ElapsedSeconds - first.ElapsedSeconds,
WorkingSetStartMb: first.WorkingSetMb,
WorkingSetEndMb: last.WorkingSetMb,
WorkingSetPeakMb: window.Max(s => s.WorkingSetMb),
WorkingSetSlopeMbPerMinute: Slope(window, s => s.WorkingSetMb) * 60.0,
ManagedHeapStartMb: first.ManagedHeapMb,
ManagedHeapEndMb: last.ManagedHeapMb,
ManagedHeapPeakMb: window.Max(s => s.ManagedHeapMb),
ManagedHeapSlopeMbPerMinute: Slope(window, s => s.ManagedHeapMb) * 60.0,
MeanCpuPercentOfOneCore: window.Average(s => s.CpuPercent),
PeakCpuPercentOfOneCore: window.Max(s => s.CpuPercent),
MeanThreadCount: window.Average(s => s.ThreadCount),
Gen2Collections: last.Gen2Collections - first.Gen2Collections);
}
private static double Slope(IReadOnlyList<ResourceSample> samples, Func<ResourceSample, double> selector)
{
var n = samples.Count;
var meanX = samples.Average(s => s.ElapsedSeconds);
var meanY = samples.Average(selector);
double numerator = 0, denominator = 0;
for (var i = 0; i < n; i++)
{
var dx = samples[i].ElapsedSeconds - meanX;
numerator += dx * (selector(samples[i]) - meanY);
denominator += dx * dx;
}
return denominator == 0 ? 0 : numerator / denominator;
}
private int _disposed;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
await _cts.CancelAsync();
try
{
await _loop;
}
catch (OperationCanceledException)
{
// Expected.
}
_cts.Dispose();
}
}
/// <summary>Aggregate resource behaviour over a measurement window.</summary>
/// <param name="SampleCount">Samples in the window.</param>
/// <param name="DurationSeconds">Window length.</param>
/// <param name="WorkingSetStartMb">Working set at window start.</param>
/// <param name="WorkingSetEndMb">Working set at window end.</param>
/// <param name="WorkingSetPeakMb">Peak working set in the window.</param>
/// <param name="WorkingSetSlopeMbPerMinute">Least-squares working-set growth rate.</param>
/// <param name="ManagedHeapStartMb">Managed heap at window start.</param>
/// <param name="ManagedHeapEndMb">Managed heap at window end.</param>
/// <param name="ManagedHeapPeakMb">Peak managed heap in the window.</param>
/// <param name="ManagedHeapSlopeMbPerMinute">Least-squares managed-heap growth rate.</param>
/// <param name="MeanCpuPercentOfOneCore">Mean CPU as a percentage of one core (1400% = 14 cores saturated).</param>
/// <param name="PeakCpuPercentOfOneCore">Peak single-sample CPU as a percentage of one core.</param>
/// <param name="MeanThreadCount">Mean OS thread count.</param>
/// <param name="Gen2Collections">Gen-2 collections during the window.</param>
public sealed record ResourceWindowSummary(
int SampleCount,
double DurationSeconds,
double WorkingSetStartMb,
double WorkingSetEndMb,
double WorkingSetPeakMb,
double WorkingSetSlopeMbPerMinute,
double ManagedHeapStartMb,
double ManagedHeapEndMb,
double ManagedHeapPeakMb,
double ManagedHeapSlopeMbPerMinute,
double MeanCpuPercentOfOneCore,
double PeakCpuPercentOfOneCore,
double MeanThreadCount,
int Gen2Collections);