20f6b0b969
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.
203 lines
7.8 KiB
C#
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);
|