20f6b0b969
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.
206 lines
8.1 KiB
C#
206 lines
8.1 KiB
C#
using System.Threading.Channels;
|
|
using Akka.Actor;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
using ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Probes;
|
|
|
|
/// <summary>
|
|
/// A live site-stream subscriber assembled from the SAME parts
|
|
/// <c>SiteStreamGrpcServer.RunSubscriptionStreamAsync</c> uses:
|
|
///
|
|
/// <list type="number">
|
|
/// <item><description><c>SiteStreamManager.Subscribe</c> — materializes the
|
|
/// per-subscriber graph (<c>Where</c> instance filter → <c>Buffer(StreamBufferSize,
|
|
/// DropHead)</c> → <c>Sink.ForEach(Tell)</c>).</description></item>
|
|
/// <item><description>A real <see cref="StreamRelayActor"/>, which converts the Akka
|
|
/// record to the protobuf <c>SiteStreamEvent</c> and <c>TryWrite</c>s it.</description></item>
|
|
/// <item><description>A bounded <c>DropOldest</c> <see cref="Channel"/> of the
|
|
/// production capacity (<c>GrpcInstanceStreamChannelCapacity</c> = 1000) with the
|
|
/// eviction counter wired to <see cref="DroppedEvents"/>.</description></item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// The single substitution is the final hop: instead of
|
|
/// <c>responseStream.WriteAsync</c> pushing onto a socket, a reader task drains the
|
|
/// channel. That is deliberate — it is precisely the hop whose slowness register row
|
|
/// 50 asks about, and a controllable reader is the only way to hold it still.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class StreamSubscriberProbe : IAsyncDisposable
|
|
{
|
|
/// <summary>Production per-instance stream channel capacity (<c>GrpcInstanceStreamChannelCapacity</c>).</summary>
|
|
public const int ProductionChannelCapacity = 1000;
|
|
|
|
private readonly SiteStreamManager _manager;
|
|
private readonly string _subscriptionId;
|
|
private readonly IActorRef _relayActor;
|
|
private readonly ActorSystem _system;
|
|
private readonly Channel<SiteStreamEvent> _channel;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private readonly Task _readerTask;
|
|
private volatile LatencyHistogram? _latency;
|
|
|
|
private readonly DropCounter _dropCounter;
|
|
private long _received;
|
|
private long _readerDelayMicroseconds;
|
|
private int _disposed;
|
|
|
|
/// <summary>Human-readable probe name (also the relay actor's name suffix).</summary>
|
|
public string Name { get; }
|
|
|
|
/// <summary>Events evicted by the bounded channel's DropOldest policy.</summary>
|
|
public long DroppedEvents => _dropCounter.Value;
|
|
|
|
/// <summary>Events successfully drained by the reader (i.e. "sent to the client").</summary>
|
|
public long ReceivedEvents => Interlocked.Read(ref _received);
|
|
|
|
/// <summary>
|
|
/// Repoints the latency histogram this probe records into, without tearing the
|
|
/// subscription down. Used to separate ramp-window samples from steady-state ones:
|
|
/// re-attaching probes instead would open a zero-subscriber gap (during which
|
|
/// <c>PublishAttributeValueChanged</c> short-circuits) and risk reusing an actor
|
|
/// name whose previous incarnation has not finished terminating.
|
|
/// </summary>
|
|
/// <param name="latency">The histogram to record into from now on, or null to stop recording.</param>
|
|
public void RetargetLatency(LatencyHistogram? latency) => _latency = latency;
|
|
|
|
/// <summary>
|
|
/// Artificial per-event reader delay, in microseconds. Zero is a healthy
|
|
/// subscriber; a large value models a stalled WAN link or a wedged client.
|
|
/// </summary>
|
|
public long ReaderDelayMicroseconds
|
|
{
|
|
get => Interlocked.Read(ref _readerDelayMicroseconds);
|
|
set => Interlocked.Exchange(ref _readerDelayMicroseconds, value);
|
|
}
|
|
|
|
private StreamSubscriberProbe(
|
|
ActorSystem system,
|
|
SiteStreamManager manager,
|
|
string name,
|
|
Channel<SiteStreamEvent> channel,
|
|
IActorRef relayActor,
|
|
string subscriptionId,
|
|
LatencyHistogram? latency,
|
|
DropCounter dropCounter)
|
|
{
|
|
_system = system;
|
|
_manager = manager;
|
|
Name = name;
|
|
_channel = channel;
|
|
_relayActor = relayActor;
|
|
_subscriptionId = subscriptionId;
|
|
_latency = latency;
|
|
_dropCounter = dropCounter;
|
|
_readerTask = Task.Run(() => ReadLoopAsync(_cts.Token));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds and attaches a probe subscribed to one instance's events.
|
|
/// </summary>
|
|
/// <param name="system">The site actor system.</param>
|
|
/// <param name="manager">The site stream manager to subscribe against.</param>
|
|
/// <param name="instanceUniqueName">Instance whose events this probe receives.</param>
|
|
/// <param name="name">Probe name, used for the relay actor's path.</param>
|
|
/// <param name="latency">Optional histogram fed with end-to-end event latency.</param>
|
|
/// <returns>The attached probe.</returns>
|
|
public static StreamSubscriberProbe Attach(
|
|
ActorSystem system,
|
|
SiteStreamManager manager,
|
|
string instanceUniqueName,
|
|
string name,
|
|
LatencyHistogram? latency)
|
|
{
|
|
var dropCounter = new DropCounter();
|
|
var channel = Channel.CreateBounded<SiteStreamEvent>(
|
|
new BoundedChannelOptions(ProductionChannelCapacity)
|
|
{
|
|
FullMode = BoundedChannelFullMode.DropOldest,
|
|
},
|
|
_ => dropCounter.Increment());
|
|
|
|
var relayActor = system.ActorOf(
|
|
Props.Create(typeof(StreamRelayActor), name, channel.Writer),
|
|
$"stream-relay-{name}");
|
|
|
|
var subscriptionId = manager.Subscribe(instanceUniqueName, relayActor);
|
|
|
|
return new StreamSubscriberProbe(
|
|
system, manager, name, channel, relayActor, subscriptionId, latency, dropCounter);
|
|
}
|
|
|
|
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await foreach (var evt in _channel.Reader.ReadAllAsync(cancellationToken))
|
|
{
|
|
Interlocked.Increment(ref _received);
|
|
|
|
var latency = _latency;
|
|
if (latency != null && evt.AttributeChanged != null)
|
|
{
|
|
// The emit instant travels verbatim: the driver stamps it on
|
|
// TagValueUpdate.Timestamp, DataConnectionActor forwards it,
|
|
// InstanceActor copies it onto AttributeValueChanged.Timestamp, and
|
|
// StreamRelayActor maps it onto the proto Timestamp. So this is a
|
|
// true end-to-end DCL-boundary → subscriber measurement.
|
|
var emitted = evt.AttributeChanged.Timestamp.ToDateTimeOffset();
|
|
latency.Record(DateTimeOffset.UtcNow - emitted);
|
|
}
|
|
|
|
var delay = Interlocked.Read(ref _readerDelayMicroseconds);
|
|
if (delay > 0)
|
|
await Task.Delay(TimeSpan.FromMicroseconds(delay), cancellationToken);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal teardown.
|
|
}
|
|
}
|
|
|
|
/// <summary>Detaches the subscription and stops the relay actor and reader.</summary>
|
|
/// <returns>A task that completes when the probe is torn down.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
|
return;
|
|
|
|
_manager.Unsubscribe(_subscriptionId);
|
|
_channel.Writer.TryComplete();
|
|
await _cts.CancelAsync();
|
|
|
|
try
|
|
{
|
|
await _readerTask;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected.
|
|
}
|
|
|
|
_system.Stop(_relayActor);
|
|
_cts.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thread-safe counter for a bounded channel's <c>itemDropped</c> callback. A tiny
|
|
/// class rather than a captured local so the probe and the channel share exactly one
|
|
/// counter instance without a second closure.
|
|
/// </summary>
|
|
public sealed class DropCounter
|
|
{
|
|
private long _value;
|
|
|
|
/// <summary>Current count.</summary>
|
|
public long Value => Interlocked.Read(ref _value);
|
|
|
|
/// <summary>Increments the counter.</summary>
|
|
public void Increment() => Interlocked.Increment(ref _value);
|
|
}
|