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;
///
/// 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.
///
///
/// One driver task per data connection, because that mirrors production: each
/// DataConnectionActor 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.
///
///
/// The emitter walks the connection's tag list in slices sized so that one full pass
/// takes , 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 so the results
/// can say honestly whether the offered load was actually delivered.
///
///
public sealed class TagUpdateDriver : IAsyncDisposable
{
private const int SlicesPerPeriod = 20;
private readonly List _tasks = new();
private readonly CancellationTokenSource _cts = new();
private long _emitted;
private long _skippedNoCallback;
private long _lagTicks;
/// Total tag value changes handed to adapter callbacks.
public long EmittedCount => Interlocked.Read(ref _emitted);
///
/// 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.
///
public long SkippedNoCallback => Interlocked.Read(ref _skippedNoCallback);
///
/// 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.
///
public double EmitLagSeconds => Interlocked.Read(ref _lagTicks) / (double)Stopwatch.Frequency;
///
/// Starts one emitter task per connection across every site.
///
/// The sites to drive.
/// Harness configuration supplying the update period.
/// The running driver.
public static TagUpdateDriver Start(IReadOnlyList 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);
}
}
}
///
/// 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.
///
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;
///
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();
}
}