using System.Threading.Channels; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// /// Broadcasts MxEvents to the two dashboard audiences for a session: remote /// clients subscribed to the session's group, and /// in-process subscribers opened through /// . Fire-and-forget: we /// hand the send to the hub context and return immediately so the source gRPC /// stream is never blocked. Errors are logged once and dropped — keeping the /// SignalR mirror best-effort preserves the gRPC contract that exists today. /// /// /// When MxGateway:Dashboard:ShowTagValues is false (the default), tag /// values are stripped from a redacted copy of the event before it reaches any /// dashboard client. The source is shared with the gRPC /// event path and the reconnect replay ring, so it is never mutated in place — /// the redaction is applied to a deep clone. This is the second of two /// independent layers: decides at the /// subscribe seam which sessions a caller may observe at all (see /// ), while the redaction decides what a permitted /// subscriber sees — so the value-leak seam stays closed whatever the ACL /// admits. /// /// Hub context used to send to the session's group. /// /// Live-subscriber registry consulted before any per-event work is done. Both /// audiences register here — hub connections by their SignalR connection id, /// in-process subscriptions by a synthetic one — so the gate stays a single /// source of truth. /// /// Gateway options supplying Dashboard:ShowTagValues. /// Logger for best-effort mirror failures. public sealed class DashboardEventBroadcaster( IHubContext hubContext, EventsHubViewerRegistry viewerRegistry, IOptions options, ILogger logger) : IDashboardEventBroadcaster, IDashboardSessionEventSubscriber { /// /// Queue depth per in-process subscriber. The consumer is a Blazor page /// rendering the newest handful of events, so a burst it cannot keep up with /// is dropped oldest-first rather than allowed to grow — same best-effort /// contract the SignalR mirror already has. /// private const int InProcessQueueCapacity = 256; private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues; private readonly object _syncRoot = new(); /// /// In-process subscribers per session. Values are treated as immutable once /// stored: a subscribe or dispose swaps in a new array under /// , so can grab the reference /// and write to it after releasing the lock. /// private readonly Dictionary _inProcessSubscribers = new(StringComparer.Ordinal); /// /// Total live in-process subscribers, read without the lock so the common /// case — nobody has a session-details page open — never contends on it. /// Written only under . /// private int _inProcessSubscriberCount; /// public void Publish(string sessionId, MxEvent mxEvent) { if (string.IsNullOrEmpty(sessionId) || mxEvent is null) { return; } // Every session's dashboard-mirror subscriber calls Publish for every event, // whether or not a browser is on that session's page. Without this gate the // steady state — no dashboard viewer at all — still paid a deep protobuf // clone (redaction is on by default) plus a send to an empty SignalR group // per event. Bail before both. if (!viewerRegistry.HasViewers(sessionId)) { return; } MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent); // In-process delivery first: it is synchronous, cannot throw, and must not be // skipped by the early return the hub send's guard clause takes. DeliverInProcess(sessionId, outbound); // Wrap the Task acquisition in a try/catch so a hypothetical synchronous throw // from SendAsync (e.g. an implementation that throws before returning the Task) // cannot escape Publish. The interface contract is never-throw; fire-and-forget. Task send; try { send = hubContext.Clients .Group(EventsHub.GroupName(sessionId)) .SendAsync(EventsHub.EventMessage, outbound); } catch (Exception ex) { logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} threw synchronously.", sessionId); return; } if (!send.IsCompletedSuccessfully) { _ = send.ContinueWith( t => { if (t.Exception is { } ex) { logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} failed.", sessionId); } }, TaskScheduler.Default); } } /// public IDashboardEventSubscription Subscribe(string sessionId) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); // A synthetic connection id keeps the registry's per-connection bookkeeping // usable for a subscriber that has no SignalR connection behind it. The // "inproc-" prefix cannot collide with a SignalR connection id and makes the // origin obvious in a debugger. string connectionId = "inproc-" + Guid.NewGuid().ToString("N"); InProcessSubscription subscription = new(this, sessionId, connectionId, InProcessQueueCapacity); // Register before the subscriber becomes a delivery target, exactly as // EventsHub.SubscribeSession registers before joining the group: the reverse // order would leave a window in which this subscriber is a delivery target but // Publish's gate still reports the session unwatched, silently dropping events // it should receive. The cost of this order is at worst a redaction clone that // reaches nobody for the width of the window. viewerRegistry.AddViewer(connectionId, sessionId); lock (_syncRoot) { _inProcessSubscribers[sessionId] = _inProcessSubscribers.TryGetValue(sessionId, out InProcessSubscription[]? existing) ? [.. existing, subscription] : [subscription]; Volatile.Write(ref _inProcessSubscriberCount, _inProcessSubscriberCount + 1); } return subscription; } /// /// Hands the already-redacted event to every in-process subscriber of the /// session. Writes are non-blocking and lossy by construction, so this never /// stalls the caller's event pipeline. /// /// Session the event belongs to. /// The event as the dashboard should see it. private void DeliverInProcess(string sessionId, MxEvent outbound) { // The gate above admits hub-only viewers too, so check for in-process // subscribers before touching the lock at all. if (Volatile.Read(ref _inProcessSubscriberCount) == 0) { return; } InProcessSubscription[] subscribers; lock (_syncRoot) { if (!_inProcessSubscribers.TryGetValue(sessionId, out InProcessSubscription[]? found)) { return; } subscribers = found; } // The array is never mutated in place, so the writes happen outside the lock. foreach (InProcessSubscription subscriber in subscribers) { subscriber.TryWrite(outbound); } } /// /// Removes a disposed subscription from the delivery map and releases its /// viewer registration. Called at most once per subscription. /// /// The subscription being disposed. private void Unsubscribe(InProcessSubscription subscription) { // Drop the delivery target first and deregister after, mirroring // EventsHub.UnsubscribeSession: the mirror stays enabled for the brief overlap // rather than dropping events still owed to the session's other subscribers. lock (_syncRoot) { if (_inProcessSubscribers.TryGetValue(subscription.SessionId, out InProcessSubscription[]? existing)) { InProcessSubscription[] remaining = [.. existing.Where(candidate => !ReferenceEquals(candidate, subscription))]; // Equal lengths mean it was never in this bucket, so the counter it // would decrement is not its own to release. if (remaining.Length != existing.Length) { if (remaining.Length == 0) { // Drop the key so the map does not grow one entry per session ever viewed. _inProcessSubscribers.Remove(subscription.SessionId); } else { _inProcessSubscribers[subscription.SessionId] = remaining; } Volatile.Write(ref _inProcessSubscriberCount, _inProcessSubscriberCount - 1); } } } viewerRegistry.RemoveViewer(subscription.ConnectionId, subscription.SessionId); // The synthetic connection id is used once and never reconnects, so nothing // else will ever call ReleaseConnection for it; without this the registry // would retain an empty per-connection entry per subscription ever opened. viewerRegistry.ReleaseConnection(subscription.ConnectionId); } /// /// Produces a deep clone of with every tag-value /// field cleared, leaving tag reference, quality, status, and timestamps /// intact so the dashboard still renders the event without the value. The /// source event is left untouched because it is shared downstream with the /// gRPC stream and the replay ring. /// /// The source event to redact a copy of. /// A redacted deep clone of the event. private static MxEvent RedactValues(MxEvent source) { MxEvent redacted = source.Clone(); redacted.Value = null; if (redacted.BodyCase == MxEvent.BodyOneofCase.OnAlarmTransition) { redacted.OnAlarmTransition.CurrentValue = null; redacted.OnAlarmTransition.LimitValue = null; } return redacted; } /// /// One in-process subscriber's feed: a bounded, drop-oldest channel plus the /// registry bookkeeping that keeps 's viewer gate honest /// while the feed is live. /// private sealed class InProcessSubscription : IDashboardEventSubscription { private readonly DashboardEventBroadcaster _owner; private readonly Channel _channel; private int _disposed; /// Initializes a new instance of the class. /// Broadcaster to deregister from on disposal. /// Session whose events this subscription carries. /// Synthetic connection id registered with the viewer registry. /// Queue depth before the oldest queued event is dropped. internal InProcessSubscription( DashboardEventBroadcaster owner, string sessionId, string connectionId, int capacity) { _owner = owner; SessionId = sessionId; ConnectionId = connectionId; _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) { // DropOldest, not Wait: a write must never block the gRPC event // pipeline that calls Publish, and the newest events are the ones a // live view wants. FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); } /// public ChannelReader Reader => _channel.Reader; /// Gets the session this subscription is watching. internal string SessionId { get; } /// Gets the synthetic connection id held in the viewer registry. internal string ConnectionId { get; } /// /// Queues an event for the subscriber, dropping the oldest queued event when /// the reader has fallen behind. Never blocks and never throws. /// /// The event to queue. internal void TryWrite(MxEvent mxEvent) => _channel.Writer.TryWrite(mxEvent); /// /// Deregisters the subscription and completes its channel so a reader's /// loop ends. Idempotent — a second call does nothing, so it can never /// release a viewer count that a sibling subscription owns. /// public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } _owner.Unsubscribe(this); _channel.Writer.TryComplete(); } } }