feat(dashboard): in-process session event subscription feeds the viewer registry — SessionDetailsPage drops its /hubs/events hop
This commit is contained in:
+74
-46
@@ -1,11 +1,11 @@
|
|||||||
@page "/sessions/{SessionId}"
|
@page "/sessions/{SessionId}"
|
||||||
@inherits DashboardPageBase
|
@inherits DashboardPageBase
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.AspNetCore.SignalR.Client
|
|
||||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto
|
@using ZB.MOM.WW.MxGateway.Contracts.Proto
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject IDashboardSessionAdminService SessionAdminService
|
@inject IDashboardSessionAdminService SessionAdminService
|
||||||
|
@inject IDashboardEventBroadcaster EventBroadcaster
|
||||||
|
|
||||||
<PageTitle>Dashboard Session</PageTitle>
|
<PageTitle>Dashboard Session</PageTitle>
|
||||||
|
|
||||||
@@ -157,7 +157,8 @@ else
|
|||||||
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
|
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
|
||||||
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
|
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
|
||||||
|
|
||||||
private HubConnection? _eventsHub;
|
private IDashboardEventSubscription? _eventSubscription;
|
||||||
|
private CancellationTokenSource? _eventPumpCancellation;
|
||||||
private bool _eventsConnected;
|
private bool _eventsConnected;
|
||||||
private string? _subscribedSessionId;
|
private string? _subscribedSessionId;
|
||||||
private readonly LinkedList<MxEvent> _recentEvents = new();
|
private readonly LinkedList<MxEvent> _recentEvents = new();
|
||||||
@@ -179,13 +180,17 @@ else
|
|||||||
CanManage = SessionAdminService.CanManage(authenticationState.User);
|
CanManage = SessionAdminService.CanManage(authenticationState.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
|
// Attach/detach are synchronous now that the feed is in-process; the override
|
||||||
|
// stays on the async lifecycle member so the base class's contract is untouched.
|
||||||
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
|
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
await DetachEventsHubAsync().ConfigureAwait(false);
|
DetachEvents();
|
||||||
await AttachEventsHubAsync().ConfigureAwait(false);
|
AttachEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PendingConfirm? PendingAction { get; set; }
|
private PendingConfirm? PendingAction { get; set; }
|
||||||
@@ -261,68 +266,91 @@ else
|
|||||||
string ConfirmButtonClass,
|
string ConfirmButtonClass,
|
||||||
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> Action);
|
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> Action);
|
||||||
|
|
||||||
private async Task AttachEventsHubAsync()
|
// The dashboard runs in the same process as the broadcaster, so this page reads
|
||||||
|
// the session's mirrored events straight from it. It used to open a loopback
|
||||||
|
// SignalR connection to /hubs/events — mint a hub token, negotiate, hold a
|
||||||
|
// WebSocket, serialize every event — to reach data already sitting in memory.
|
||||||
|
// The subscription still registers with EventsHubViewerRegistry, so the
|
||||||
|
// broadcaster's "nobody is watching" gate keeps working for both audiences.
|
||||||
|
// ACL posture is unchanged from the hub path: any dashboard Viewer may watch
|
||||||
|
// any session (SEC-25 tracks the per-session ACL for both seams).
|
||||||
|
private void AttachEvents()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(SessionId))
|
if (string.IsNullOrWhiteSpace(SessionId) || EventBroadcaster is not IDashboardSessionEventSubscriber subscriber)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_eventsHub = HubFactory.Create("/hubs/events");
|
_eventSubscription = subscriber.Subscribe(SessionId);
|
||||||
_eventsHub.On<MxEvent>(EventsHub.EventMessage, async mxEvent =>
|
_eventPumpCancellation = new CancellationTokenSource();
|
||||||
|
_eventsConnected = true;
|
||||||
|
_subscribedSessionId = SessionId;
|
||||||
|
|
||||||
|
_ = PumpEventsAsync(_eventSubscription, _eventPumpCancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PumpEventsAsync(IDashboardEventSubscription subscription, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await subscription.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
// Drain what is queued and render once: a burst costs one render pass,
|
||||||
|
// not one per event. Reading past the display cap would be wasted work,
|
||||||
|
// and anything left queued is picked up on the next pass.
|
||||||
|
List<MxEvent> batch = new();
|
||||||
|
while (batch.Count < MaxRecentEvents && subscription.Reader.TryRead(out MxEvent? mxEvent))
|
||||||
|
{
|
||||||
|
batch.Add(mxEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
foreach (MxEvent mxEvent in batch)
|
||||||
{
|
{
|
||||||
_recentEvents.AddFirst(mxEvent);
|
_recentEvents.AddFirst(mxEvent);
|
||||||
|
}
|
||||||
|
|
||||||
while (_recentEvents.Count > MaxRecentEvents)
|
while (_recentEvents.Count > MaxRecentEvents)
|
||||||
{
|
{
|
||||||
_recentEvents.RemoveLast();
|
_recentEvents.RemoveLast();
|
||||||
}
|
}
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
StateHasChanged();
|
||||||
});
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
_eventsHub.Closed += _ =>
|
|
||||||
{
|
|
||||||
_eventsConnected = false;
|
|
||||||
return InvokeAsync(StateHasChanged);
|
|
||||||
};
|
|
||||||
_eventsHub.Reconnected += _ =>
|
|
||||||
{
|
|
||||||
_eventsConnected = true;
|
|
||||||
return InvokeAsync(StateHasChanged);
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _eventsHub.StartAsync().ConfigureAwait(false);
|
|
||||||
await _eventsHub.SendAsync("SubscribeSession", SessionId).ConfigureAwait(false);
|
|
||||||
_eventsConnected = true;
|
|
||||||
_subscribedSessionId = SessionId;
|
|
||||||
}
|
}
|
||||||
catch
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_eventsConnected = false;
|
// The page navigated to another session or was disposed.
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// The renderer went away while a batch was being dispatched.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DetachEventsHubAsync()
|
private void DetachEvents()
|
||||||
{
|
{
|
||||||
HubConnection? hub = _eventsHub;
|
IDashboardEventSubscription? subscription = _eventSubscription;
|
||||||
_eventsHub = null;
|
CancellationTokenSource? cancellation = _eventPumpCancellation;
|
||||||
|
_eventSubscription = null;
|
||||||
|
_eventPumpCancellation = null;
|
||||||
_eventsConnected = false;
|
_eventsConnected = false;
|
||||||
_subscribedSessionId = null;
|
_subscribedSessionId = null;
|
||||||
_recentEvents.Clear();
|
_recentEvents.Clear();
|
||||||
|
|
||||||
if (hub is not null)
|
// Cancel first so the pump stops touching the renderer, then dispose the
|
||||||
{
|
// subscription — that is what releases the viewer registration and lets the
|
||||||
try
|
// broadcaster go back to skipping mirror work for this session.
|
||||||
{
|
cancellation?.Cancel();
|
||||||
await hub.DisposeAsync().ConfigureAwait(false);
|
cancellation?.Dispose();
|
||||||
}
|
subscription?.Dispose();
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Disposal-time errors are best-effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EventStatusLabel(MxEvent evt)
|
private static string EventStatusLabel(MxEvent evt)
|
||||||
@@ -334,7 +362,7 @@ else
|
|||||||
|
|
||||||
public new async ValueTask DisposeAsync()
|
public new async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await DetachEventsHubAsync().ConfigureAwait(false);
|
DetachEvents();
|
||||||
await base.DisposeAsync().ConfigureAwait(false);
|
await base.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
@@ -6,11 +7,13 @@ using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Broadcasts MxEvents to <see cref="EventsHub"/> clients subscribed to the
|
/// Broadcasts MxEvents to the two dashboard audiences for a session: remote
|
||||||
/// session's group. Fire-and-forget: we hand the send to the hub context
|
/// <see cref="EventsHub"/> clients subscribed to the session's group, and
|
||||||
/// and return immediately so the source gRPC stream is never blocked.
|
/// in-process subscribers opened through
|
||||||
/// Errors are logged once and dropped — keeping the SignalR mirror best-effort
|
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/>. Fire-and-forget: we
|
||||||
/// preserves the gRPC contract that exists today.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), tag
|
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), tag
|
||||||
@@ -23,7 +26,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="hubContext">Hub context used to send to the session's group.</param>
|
/// <param name="hubContext">Hub context used to send to the session's group.</param>
|
||||||
/// <param name="viewerRegistry">
|
/// <param name="viewerRegistry">
|
||||||
/// Live-subscriber registry consulted before any per-event work is done.
|
/// 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.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
||||||
/// <param name="logger">Logger for best-effort mirror failures.</param>
|
/// <param name="logger">Logger for best-effort mirror failures.</param>
|
||||||
@@ -31,10 +37,36 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
IHubContext<EventsHub> hubContext,
|
IHubContext<EventsHub> hubContext,
|
||||||
EventsHubViewerRegistry viewerRegistry,
|
EventsHubViewerRegistry viewerRegistry,
|
||||||
IOptions<GatewayOptions> options,
|
IOptions<GatewayOptions> options,
|
||||||
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
|
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster, IDashboardSessionEventSubscriber
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private const int InProcessQueueCapacity = 256;
|
||||||
|
|
||||||
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
|
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
|
||||||
|
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-process subscribers per session. Values are treated as immutable once
|
||||||
|
/// stored: a subscribe or dispose swaps in a new array under
|
||||||
|
/// <see cref="_syncRoot"/>, so <see cref="Publish"/> can grab the reference
|
||||||
|
/// and write to it after releasing the lock.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, InProcessSubscription[]> _inProcessSubscribers =
|
||||||
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="_syncRoot"/>.
|
||||||
|
/// </summary>
|
||||||
|
private int _inProcessSubscriberCount;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Publish(string sessionId, MxEvent mxEvent)
|
public void Publish(string sessionId, MxEvent mxEvent)
|
||||||
{
|
{
|
||||||
@@ -55,6 +87,10 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
|
|
||||||
MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent);
|
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
|
// 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)
|
// from SendAsync (e.g. an implementation that throws before returning the Task)
|
||||||
// cannot escape Publish. The interface contract is never-throw; fire-and-forget.
|
// cannot escape Publish. The interface contract is never-throw; fire-and-forget.
|
||||||
@@ -85,6 +121,117 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">Session the event belongs to.</param>
|
||||||
|
/// <param name="outbound">The event as the dashboard should see it.</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a disposed subscription from the delivery map and releases its
|
||||||
|
/// viewer registration. Called at most once per subscription.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subscription">The subscription being disposed.</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produces a deep clone of <paramref name="source"/> with every tag-value
|
/// Produces a deep clone of <paramref name="source"/> with every tag-value
|
||||||
/// field cleared, leaving tag reference, quality, status, and timestamps
|
/// field cleared, leaving tag reference, quality, status, and timestamps
|
||||||
@@ -107,4 +254,75 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
|
|
||||||
return redacted;
|
return redacted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One in-process subscriber's feed: a bounded, drop-oldest channel plus the
|
||||||
|
/// registry bookkeeping that keeps <see cref="Publish"/>'s viewer gate honest
|
||||||
|
/// while the feed is live.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class InProcessSubscription : IDashboardEventSubscription
|
||||||
|
{
|
||||||
|
private readonly DashboardEventBroadcaster _owner;
|
||||||
|
|
||||||
|
private readonly Channel<MxEvent> _channel;
|
||||||
|
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="InProcessSubscription"/> class.</summary>
|
||||||
|
/// <param name="owner">Broadcaster to deregister from on disposal.</param>
|
||||||
|
/// <param name="sessionId">Session whose events this subscription carries.</param>
|
||||||
|
/// <param name="connectionId">Synthetic connection id registered with the viewer registry.</param>
|
||||||
|
/// <param name="capacity">Queue depth before the oldest queued event is dropped.</param>
|
||||||
|
internal InProcessSubscription(
|
||||||
|
DashboardEventBroadcaster owner,
|
||||||
|
string sessionId,
|
||||||
|
string connectionId,
|
||||||
|
int capacity)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
SessionId = sessionId;
|
||||||
|
ConnectionId = connectionId;
|
||||||
|
_channel = Channel.CreateBounded<MxEvent>(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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ChannelReader<MxEvent> Reader => _channel.Reader;
|
||||||
|
|
||||||
|
/// <summary>Gets the session this subscription is watching.</summary>
|
||||||
|
internal string SessionId { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the synthetic connection id held in the viewer registry.</summary>
|
||||||
|
internal string ConnectionId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an event for the subscriber, dropping the oldest queued event when
|
||||||
|
/// the reader has fallen behind. Never blocks and never throws.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mxEvent">The event to queue.</param>
|
||||||
|
internal void TryWrite(MxEvent mxEvent) => _channel.Writer.TryWrite(mxEvent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_owner.Unsubscribe(this);
|
||||||
|
_channel.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A live in-process feed of one session's dashboard-mirrored MxEvents, handed
|
||||||
|
/// out by <see cref="IDashboardSessionEventSubscriber.Subscribe"/>. Server-side
|
||||||
|
/// Blazor components read it directly instead of looping back through
|
||||||
|
/// <see cref="EventsHub"/> over a loopback SignalR connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Events are delivered exactly as a hub client would see them — the same
|
||||||
|
/// redacted clone the group send carries, so <c>MxGateway:Dashboard:ShowTagValues</c>
|
||||||
|
/// governs both paths identically. The feed is a bounded, lossy queue: a
|
||||||
|
/// consumer that falls behind loses the oldest queued events, matching the
|
||||||
|
/// best-effort contract the SignalR mirror already has. Disposing the
|
||||||
|
/// subscription deregisters it, which is what lets the broadcaster go back to
|
||||||
|
/// skipping all mirror work for a session nobody is watching — so callers must
|
||||||
|
/// dispose. Dispose is idempotent.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IDashboardEventSubscription : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Gets the reader delivering this session's mirrored events.</summary>
|
||||||
|
ChannelReader<MxEvent> Reader { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-process subscription seam on the dashboard event mirror. Implemented by
|
||||||
|
/// <see cref="DashboardEventBroadcaster"/> alongside
|
||||||
|
/// <see cref="IDashboardEventBroadcaster"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The interactive-server dashboard runs in the same process as the broadcaster,
|
||||||
|
/// so a session-details page has no reason to open a loopback SignalR connection
|
||||||
|
/// back to <see cref="EventsHub"/> — mint a hub token, negotiate, hold a
|
||||||
|
/// WebSocket, and serialize every event — just to read events the broadcaster
|
||||||
|
/// already holds. It subscribes here instead. The registry gate stays honest
|
||||||
|
/// either way: an in-process subscription registers a synthetic connection id
|
||||||
|
/// with <see cref="EventsHubViewerRegistry"/> exactly as the hub registers a real
|
||||||
|
/// one, so <see cref="IDashboardEventBroadcaster.Publish"/> keeps skipping the
|
||||||
|
/// redaction clone for sessions nobody is watching.
|
||||||
|
/// <para>
|
||||||
|
/// It is a separate interface rather than a member of
|
||||||
|
/// <see cref="IDashboardEventBroadcaster"/> because publishing and consuming are
|
||||||
|
/// different roles: the session pipeline only ever publishes, and its test
|
||||||
|
/// doubles should not have to implement a subscription feed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IDashboardSessionEventSubscriber
|
||||||
|
{
|
||||||
|
/// <summary>Opens an in-process feed of the session's mirrored events.</summary>
|
||||||
|
/// <param name="sessionId">Session id whose events the caller wants.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The subscription. Dispose it to stop the feed and release the viewer
|
||||||
|
/// registration that keeps the mirror enabled for this session.
|
||||||
|
/// </returns>
|
||||||
|
IDashboardEventSubscription Subscribe(string sessionId);
|
||||||
|
}
|
||||||
@@ -121,6 +121,140 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
Assert.NotNull(sent.OnAlarmTransition.LimitValue);
|
Assert.NotNull(sent.OnAlarmTransition.LimitValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An in-process subscriber gets the same redacted clone the hub group gets,
|
||||||
|
/// and the shared source event is still left untouched.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Subscribe_WhenShowTagValuesFalse_DeliversRedactedCloneWithoutMutatingSource()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
EventsHubViewerRegistry viewers = new();
|
||||||
|
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
||||||
|
MxEvent source = BuildEventWithValue();
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", source);
|
||||||
|
|
||||||
|
MxEvent received = ReadOne(subscription);
|
||||||
|
Assert.Null(received.Value);
|
||||||
|
Assert.Null(received.OnAlarmTransition.CurrentValue);
|
||||||
|
Assert.Null(received.OnAlarmTransition.LimitValue);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", received.OnAlarmTransition.AlarmFullReference);
|
||||||
|
|
||||||
|
// One clone feeds both audiences — the hub group and the in-process feed.
|
||||||
|
Assert.Same(hubContext.LastArgument, received);
|
||||||
|
|
||||||
|
// The source is shared with the gRPC stream and the replay ring.
|
||||||
|
Assert.NotSame(source, received);
|
||||||
|
Assert.NotNull(source.Value);
|
||||||
|
Assert.Equal(42.5, source.Value.DoubleValue);
|
||||||
|
Assert.NotNull(source.OnAlarmTransition.CurrentValue);
|
||||||
|
Assert.NotNull(source.OnAlarmTransition.LimitValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An in-process subscription opens the viewer gate the same way a hub client does.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Subscribe_OpensTheViewerGate()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
EventsHubViewerRegistry viewers = new();
|
||||||
|
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
Assert.Equal(0, hubContext.SendCount);
|
||||||
|
Assert.Null(hubContext.LastArgument);
|
||||||
|
|
||||||
|
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
||||||
|
|
||||||
|
Assert.True(viewers.HasViewers("session-1"));
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
|
Assert.NotNull(hubContext.LastArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposing the last in-process subscription restores the no-viewers short-circuit.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_OfLastInProcessSubscription_RestoresTheShortCircuit()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
EventsHubViewerRegistry viewers = new();
|
||||||
|
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
IDashboardEventSubscription subscription = broadcaster.Subscribe("session-1");
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
|
Assert.Equal("session-1", ReadOne(subscription).SessionId);
|
||||||
|
|
||||||
|
subscription.Dispose();
|
||||||
|
|
||||||
|
Assert.False(viewers.HasViewers("session-1"));
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
|
Assert.False(subscription.Reader.TryRead(out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hub viewers and in-process subscribers are audiences of their own session only.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Publish_DeliversOnlyToTheSubscribedSessionsAudience()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
EventsHubViewerRegistry viewers = new();
|
||||||
|
viewers.AddViewer("conn-1", "session-1");
|
||||||
|
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
using IDashboardEventSubscription subscription = broadcaster.Subscribe("session-2");
|
||||||
|
|
||||||
|
// The hub viewer's session must not spill into the in-process feed.
|
||||||
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
|
Assert.False(subscription.Reader.TryRead(out _));
|
||||||
|
|
||||||
|
broadcaster.Publish("session-2", BuildEventWithValue("session-2"));
|
||||||
|
|
||||||
|
Assert.Equal("session-2", ReadOne(subscription).SessionId);
|
||||||
|
|
||||||
|
// A session with no audience at all still short-circuits.
|
||||||
|
broadcaster.Publish("session-3", BuildEventWithValue("session-3"));
|
||||||
|
|
||||||
|
Assert.Equal(2, hubContext.SendCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposing twice is a no-op and cannot release a sibling subscription's registration.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_CalledTwice_IsSafeAndLeavesSiblingSubscriptionsAlone()
|
||||||
|
{
|
||||||
|
CapturingHubContext hubContext = new();
|
||||||
|
EventsHubViewerRegistry viewers = new();
|
||||||
|
DashboardEventBroadcaster broadcaster = Create(hubContext, showTagValues: false, viewers);
|
||||||
|
IDashboardEventSubscription first = broadcaster.Subscribe("session-1");
|
||||||
|
using IDashboardEventSubscription second = broadcaster.Subscribe("session-1");
|
||||||
|
|
||||||
|
first.Dispose();
|
||||||
|
first.Dispose();
|
||||||
|
|
||||||
|
Assert.True(viewers.HasViewers("session-1"));
|
||||||
|
|
||||||
|
broadcaster.Publish("session-1", BuildEventWithValue());
|
||||||
|
|
||||||
|
Assert.Equal(1, hubContext.SendCount);
|
||||||
|
Assert.False(first.Reader.TryRead(out _));
|
||||||
|
Assert.Equal("session-1", ReadOne(second).SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads exactly one event from a subscription, failing the test if none is queued.</summary>
|
||||||
|
/// <param name="subscription">The subscription to read from.</param>
|
||||||
|
/// <returns>The event that was read.</returns>
|
||||||
|
private static MxEvent ReadOne(IDashboardEventSubscription subscription)
|
||||||
|
{
|
||||||
|
Assert.True(subscription.Reader.TryRead(out MxEvent? received));
|
||||||
|
return Assert.IsType<MxEvent>(received);
|
||||||
|
}
|
||||||
|
|
||||||
private static DashboardEventBroadcaster Create(
|
private static DashboardEventBroadcaster Create(
|
||||||
CapturingHubContext hubContext,
|
CapturingHubContext hubContext,
|
||||||
bool showTagValues,
|
bool showTagValues,
|
||||||
@@ -147,12 +281,15 @@ public sealed class DashboardEventBroadcasterTests
|
|||||||
return viewers;
|
return viewers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MxEvent BuildEventWithValue()
|
/// <summary>Builds a value-bearing alarm-transition event for the given session.</summary>
|
||||||
|
/// <param name="sessionId">Session id stamped on the event.</param>
|
||||||
|
/// <returns>The event.</returns>
|
||||||
|
private static MxEvent BuildEventWithValue(string sessionId = "session-1")
|
||||||
{
|
{
|
||||||
return new MxEvent
|
return new MxEvent
|
||||||
{
|
{
|
||||||
Family = MxEventFamily.OnAlarmTransition,
|
Family = MxEventFamily.OnAlarmTransition,
|
||||||
SessionId = "session-1",
|
SessionId = sessionId,
|
||||||
ServerHandle = 7,
|
ServerHandle = 7,
|
||||||
ItemHandle = 11,
|
ItemHandle = 11,
|
||||||
Quality = 192,
|
Quality = 192,
|
||||||
|
|||||||
Reference in New Issue
Block a user