Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/Metrics/LatencyHistogram.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

137 lines
4.9 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics;
/// <summary>
/// Lock-free logarithmic latency histogram sized for tens of thousands of samples
/// per second across many threads.
///
/// <para>
/// Buckets are 16-per-octave over microseconds, i.e. bucket <c>i</c> covers
/// <c>[2^(i/16), 2^((i+1)/16))</c> µs. That bounds relative bucket width at
/// <c>2^(1/16) - 1 ≈ 4.4%</c>, so a reported percentile is within ~4.4% of the true
/// value — ample for the millisecond-scale thresholds this harness asserts, and far
/// cheaper than retaining 45 million raw samples.
/// </para>
/// <para>
/// Recording is a <see cref="Math.Log2(double)"/> plus one
/// <see cref="Interlocked.Increment(ref long)"/>; there is no allocation on the hot path.
/// </para>
/// </summary>
public sealed class LatencyHistogram
{
private const int SubBucketsPerOctave = 16;
private const int BucketCount = 64 * SubBucketsPerOctave;
private readonly long[] _buckets = new long[BucketCount];
private long _count;
private long _totalMicroseconds;
private long _maxMicroseconds;
/// <summary>Number of samples recorded.</summary>
public long Count => Interlocked.Read(ref _count);
/// <summary>Largest sample seen, in microseconds (exact — not bucketed).</summary>
public double MaxMs => Interlocked.Read(ref _maxMicroseconds) / 1000.0;
/// <summary>Arithmetic mean in milliseconds (exact — accumulated, not bucketed).</summary>
public double MeanMs
{
get
{
var count = Interlocked.Read(ref _count);
return count == 0 ? 0 : Interlocked.Read(ref _totalMicroseconds) / 1000.0 / count;
}
}
/// <summary>
/// Records one sample. Negative durations (clock skew across the emit/receive
/// boundary) are clamped to zero rather than discarded, so the sample count stays
/// an honest denominator.
/// </summary>
/// <param name="elapsed">The measured latency.</param>
public void Record(TimeSpan elapsed)
{
var micros = (long)(elapsed.TotalMilliseconds * 1000.0);
if (micros < 0) micros = 0;
Interlocked.Increment(ref _count);
Interlocked.Add(ref _totalMicroseconds, micros);
long observedMax;
while (micros > (observedMax = Interlocked.Read(ref _maxMicroseconds)))
{
if (Interlocked.CompareExchange(ref _maxMicroseconds, micros, observedMax) == observedMax)
break;
}
Interlocked.Increment(ref _buckets[BucketIndex(micros)]);
}
private static int BucketIndex(long micros)
{
if (micros <= 0) return 0;
var index = (int)(Math.Log2(micros) * SubBucketsPerOctave);
if (index < 0) return 0;
return index >= BucketCount ? BucketCount - 1 : index;
}
/// <summary>Bucket midpoint in milliseconds, used when reconstructing a percentile.</summary>
private static double BucketMidpointMs(int index)
{
var low = Math.Pow(2, (double)index / SubBucketsPerOctave);
var high = Math.Pow(2, (double)(index + 1) / SubBucketsPerOctave);
return (low + high) / 2.0 / 1000.0;
}
/// <summary>
/// Returns the requested percentile in milliseconds, or 0 when no samples were recorded.
/// </summary>
/// <param name="percentile">Percentile in the range 0..100 (e.g. 99 for P99).</param>
/// <returns>The percentile value in milliseconds.</returns>
public double PercentileMs(double percentile)
{
var total = Interlocked.Read(ref _count);
if (total == 0) return 0;
var target = (long)Math.Ceiling(total * percentile / 100.0);
if (target < 1) target = 1;
long cumulative = 0;
for (var i = 0; i < BucketCount; i++)
{
cumulative += Interlocked.Read(ref _buckets[i]);
if (cumulative >= target)
return BucketMidpointMs(i);
}
return MaxMs;
}
/// <summary>Materializes the standard percentile set plus mean/max/count for reporting.</summary>
/// <returns>A snapshot record of this histogram.</returns>
public LatencySnapshot Snapshot() => new(
Count,
MeanMs,
PercentileMs(50),
PercentileMs(95),
PercentileMs(99),
PercentileMs(99.9),
MaxMs);
}
/// <summary>Point-in-time summary of a <see cref="LatencyHistogram"/>. All times in milliseconds.</summary>
/// <param name="Count">Samples recorded.</param>
/// <param name="MeanMs">Arithmetic mean.</param>
/// <param name="P50Ms">Median.</param>
/// <param name="P95Ms">95th percentile.</param>
/// <param name="P99Ms">99th percentile.</param>
/// <param name="P999Ms">99.9th percentile.</param>
/// <param name="MaxMs">Largest observed sample.</param>
public sealed record LatencySnapshot(
long Count,
double MeanMs,
double P50Ms,
double P95Ms,
double P99Ms,
double P999Ms,
double MaxMs);