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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user