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.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user