Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.LoadHarness/TagUpdateDriver.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

177 lines
6.8 KiB
C#

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 the load test 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;
/// <summary>Cancels the emitter tasks, waits for them to finish and releases the cancellation source.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
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();
}
}