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; /// /// A live site-stream subscriber assembled from the SAME parts /// SiteStreamGrpcServer.RunSubscriptionStreamAsync uses: /// /// /// SiteStreamManager.Subscribe — materializes the /// per-subscriber graph (Where instance filter → Buffer(StreamBufferSize, /// DropHead)Sink.ForEach(Tell)). /// A real , which converts the Akka /// record to the protobuf SiteStreamEvent and TryWrites it. /// A bounded DropOldest of the /// production capacity (GrpcInstanceStreamChannelCapacity = 1000) with the /// eviction counter wired to . /// /// /// /// The single substitution is the final hop: instead of /// responseStream.WriteAsync 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. /// /// public sealed class StreamSubscriberProbe : IAsyncDisposable { /// Production per-instance stream channel capacity (GrpcInstanceStreamChannelCapacity). public const int ProductionChannelCapacity = 1000; private readonly SiteStreamManager _manager; private readonly string _subscriptionId; private readonly IActorRef _relayActor; private readonly ActorSystem _system; private readonly Channel _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; /// Human-readable probe name (also the relay actor's name suffix). public string Name { get; } /// Events evicted by the bounded channel's DropOldest policy. public long DroppedEvents => _dropCounter.Value; /// Events successfully drained by the reader (i.e. "sent to the client"). public long ReceivedEvents => Interlocked.Read(ref _received); /// /// 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 /// PublishAttributeValueChanged short-circuits) and risk reusing an actor /// name whose previous incarnation has not finished terminating. /// /// The histogram to record into from now on, or null to stop recording. public void RetargetLatency(LatencyHistogram? latency) => _latency = latency; /// /// Artificial per-event reader delay, in microseconds. Zero is a healthy /// subscriber; a large value models a stalled WAN link or a wedged client. /// public long ReaderDelayMicroseconds { get => Interlocked.Read(ref _readerDelayMicroseconds); set => Interlocked.Exchange(ref _readerDelayMicroseconds, value); } private StreamSubscriberProbe( ActorSystem system, SiteStreamManager manager, string name, Channel 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)); } /// /// Builds and attaches a probe subscribed to one instance's events. /// /// The site actor system. /// The site stream manager to subscribe against. /// Instance whose events this probe receives. /// Probe name, used for the relay actor's path. /// Optional histogram fed with end-to-end event latency. /// The attached probe. public static StreamSubscriberProbe Attach( ActorSystem system, SiteStreamManager manager, string instanceUniqueName, string name, LatencyHistogram? latency) { var dropCounter = new DropCounter(); var channel = Channel.CreateBounded( 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. } } /// Detaches the subscription and stops the relay actor and reader. /// A task that completes when the probe is torn down. 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(); } } /// /// Thread-safe counter for a bounded channel's itemDropped callback. A tiny /// class rather than a captured local so the probe and the channel share exactly one /// counter instance without a second closure. /// public sealed class DropCounter { private long _value; /// Current count. public long Value => Interlocked.Read(ref _value); /// Increments the counter. public void Increment() => Interlocked.Increment(ref _value); }