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.
137 lines
4.9 KiB
C#
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);
|