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(); } }