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:
Joseph Doherty
2026-08-15 02:23:04 -04:00
parent 986e6e7ad5
commit 20f6b0b969
17 changed files with 2419 additions and 0 deletions
@@ -0,0 +1,175 @@
using System.Diagnostics;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.LoadHarness;
/// <summary>
/// Drives simulated tag value changes into a site's connections at a fixed nominal
/// rate, standing in for an OPC UA server's notification thread.
///
/// <para>
/// One driver task per data connection, because that mirrors production: each
/// <c>DataConnectionActor</c> has exactly one adapter feeding it, and every update
/// for that connection funnels through that actor's single mailbox. Sharding the
/// emitters differently would hide the per-connection-actor serialization point,
/// which is one of the things WP-4 needs to characterize.
/// </para>
/// <para>
/// The emitter walks the connection's tag list in slices sized so that one full pass
/// takes <see cref="HarnessConfig.TagUpdatePeriod"/>, then sleeps out the remainder
/// of each slice's budget. If a slice overruns its budget the driver does NOT try to
/// catch up — it records the shortfall in <see cref="EmitLagSeconds"/> so the results
/// can say honestly whether the offered load was actually delivered.
/// </para>
/// </summary>
public sealed class TagUpdateDriver : IAsyncDisposable
{
private const int SlicesPerPeriod = 20;
private readonly List<Task> _tasks = new();
private readonly CancellationTokenSource _cts = new();
private long _emitted;
private long _skippedNoCallback;
private long _lagTicks;
/// <summary>Total tag value changes handed to adapter callbacks.</summary>
public long EmittedCount => Interlocked.Read(ref _emitted);
/// <summary>
/// Emissions skipped because no Instance Actor had subscribed to that connection
/// yet (the adapter callback is captured at subscribe time). Non-zero only during
/// the ramp; a non-zero value in the steady window would mean lost offered load.
/// </summary>
public long SkippedNoCallback => Interlocked.Read(ref _skippedNoCallback);
/// <summary>
/// Cumulative seconds by which emit slices overran their time budget, summed
/// across driver tasks. Large values mean the harness itself could not offer the
/// nominal rate and the measured throughput is driver-bound, not system-bound.
/// </summary>
public double EmitLagSeconds => Interlocked.Read(ref _lagTicks) / (double)Stopwatch.Frequency;
/// <summary>
/// Starts one emitter task per connection across every site.
/// </summary>
/// <param name="sites">The sites to drive.</param>
/// <param name="config">Harness configuration supplying the update period.</param>
/// <returns>The running driver.</returns>
public static TagUpdateDriver Start(IReadOnlyList<SiteRuntimeFixture> sites, HarnessConfig config)
{
var driver = new TagUpdateDriver();
foreach (var site in sites)
{
for (var c = 0; c < site.TagPathsByConnection.Count; c++)
{
var connectionIndex = c;
var tagPaths = site.TagPathsByConnection[connectionIndex].ToArray();
driver._tasks.Add(Task.Run(() => driver.EmitLoopAsync(
site, connectionIndex, tagPaths, config.TagUpdatePeriod, driver._cts.Token)));
}
}
return driver;
}
private async Task EmitLoopAsync(
SiteRuntimeFixture site,
int connectionIndex,
string[] tagPaths,
TimeSpan period,
CancellationToken cancellationToken)
{
if (tagPaths.Length == 0)
return;
var sliceBudget = period / SlicesPerPeriod;
var sliceSize = Math.Max(1, (int)Math.Ceiling(tagPaths.Length / (double)SlicesPerPeriod));
var cursor = 0;
var sequence = 0d;
while (!cancellationToken.IsCancellationRequested)
{
var sliceStart = Stopwatch.GetTimestamp();
var callback = ResolveCallback(site, connectionIndex);
if (callback == null)
{
Interlocked.Add(ref _skippedNoCallback, sliceSize);
}
else
{
var end = Math.Min(cursor + sliceSize, tagPaths.Length);
for (var i = cursor; i < end; i++)
{
// Stamped HERE: this instant rides TagValueUpdate.Timestamp all the
// way to the subscriber, so the probe's subtraction is a genuine
// end-to-end latency and not a re-stamped approximation.
callback(tagPaths[i], new TagValue(sequence, QualityCode.Good, DateTimeOffset.UtcNow));
}
Interlocked.Add(ref _emitted, end - cursor);
cursor = end;
}
if (cursor >= tagPaths.Length)
{
cursor = 0;
sequence += 1d;
}
var elapsed = Stopwatch.GetTimestamp() - sliceStart;
var budgetTicks = (long)(sliceBudget.TotalSeconds * Stopwatch.Frequency);
if (elapsed < budgetTicks)
{
var remaining = TimeSpan.FromSeconds((budgetTicks - elapsed) / (double)Stopwatch.Frequency);
try
{
await Task.Delay(remaining, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
}
else
{
Interlocked.Add(ref _lagTicks, elapsed - budgetTicks);
}
}
}
/// <summary>
/// Resolves the live adapter callback for a connection, by NAME. The DCL factory
/// appends adapters as its manager actor processes CreateConnectionCommands, so
/// list position does not track connection index.
/// </summary>
private static SubscriptionCallback? ResolveCallback(SiteRuntimeFixture site, int connectionIndex)
{
var name = SiteRuntimeFixture.ConnectionName(connectionIndex);
// Last match wins: a reconnect would create a fresh adapter for the same name,
// and only the newest one holds the live subscription callback.
return site.Connections.LastOrDefault(c => c.ConnectionName == name)?.ValueCallback;
}
private int _disposed;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
await _cts.CancelAsync();
try
{
await Task.WhenAll(_tasks);
}
catch (OperationCanceledException)
{
// Expected on shutdown.
}
_cts.Dispose();
}
}