using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Default . Owns one shared gateway /// session for the whole dashboard: it is opened lazily on first use and /// re-opened transparently whenever it faults, is closed, or its lease /// expires. All access is serialised through so the /// single backing worker only ever sees one in-flight command. /// public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable { private const string BackendName = "Galaxy"; private const string ClientName = "mxgateway-dashboard"; private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); private readonly ISessionManager _sessionManager; private readonly IGatewayAlarmService _alarmService; private readonly ILogger _logger; private readonly SemaphoreSlim _gate = new(1, 1); private readonly HashSet _subscribed = new(StringComparer.OrdinalIgnoreCase); private GatewaySession? _session; private int _serverHandle; private bool _disposed; /// Initializes the live-data service. /// Gateway session manager. /// Gateway central alarm service. /// Diagnostic logger. public DashboardLiveDataService( ISessionManager sessionManager, IGatewayAlarmService alarmService, ILogger logger) { _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); _alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task ReadAsync( IReadOnlyCollection tagAddresses, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(tagAddresses); if (tagAddresses.Count == 0) { return DashboardLiveReadResult.Empty; } await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { (GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken) .ConfigureAwait(false); string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray(); if (toSubscribe.Length > 0) { await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken) .ConfigureAwait(false); foreach (string tag in toSubscribe) { _subscribed.Add(tag); } } IReadOnlyList results = await session .ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken) .ConfigureAwait(false); DashboardTagValue[] values = results .Select(DashboardTagValue.FromBulkReadResult) .ToArray(); return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId); } catch (Exception exception) when (exception is not OperationCanceledException) { InvalidateSession(); _logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened."); return new DashboardLiveReadResult([], exception.Message, null, null); } finally { _gate.Release(); } } /// public Task QueryAlarmsAsync(CancellationToken cancellationToken) { // Alarms come from the gateway's always-on central monitor; the // dashboard reads its in-process cache directly — no session needed. DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms .Select(DashboardActiveAlarm.FromSnapshot) .ToArray(); string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring or GatewayAlarmMonitorState.Disabled ? null : _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}."; return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId)); } // Returns a Ready session + its Register server handle, opening a fresh // session when none exists or the current one is no longer usable. Callers // must hold _gate. private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync( CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); GatewaySession? existing = _session; if (existing is not null && existing.State == SessionState.Ready && _sessionManager.TryGetSession(existing.SessionId, out _)) { return (existing, _serverHandle); } if (existing is not null) { _logger.LogInformation( "Dashboard session {SessionId} is no longer usable (state {State}); re-opening.", existing.SessionId, existing.State); await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false); } _subscribed.Clear(); _session = null; GatewaySession session = await _sessionManager.OpenSessionAsync( new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null), ClientName, cancellationToken) .ConfigureAwait(false); WorkerCommandReply reply = await session.InvokeAsync( new WorkerCommand { Command = new MxCommand { Kind = MxCommandKind.Register, Register = new RegisterCommand { ClientName = ClientName }, }, }, cancellationToken) .ConfigureAwait(false); int? serverHandle = reply.Reply?.Register?.ServerHandle; if (serverHandle is null) { string diagnostic = reply.Reply?.ProtocolStatus?.Message ?? reply.Reply?.DiagnosticMessage ?? "Worker did not return a server handle for Register."; await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false); throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}"); } _session = session; _serverHandle = serverHandle.Value; _logger.LogInformation( "Dashboard session {SessionId} opened (worker pid {WorkerPid}).", session.SessionId, session.WorkerProcessId); return (session, _serverHandle); } // Drops the cached session so the next call re-opens. Callers must hold _gate. private void InvalidateSession() { _session = null; _serverHandle = 0; _subscribed.Clear(); } private async Task CloseQuietlyAsync(string sessionId) { try { await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false); } catch (Exception exception) { _logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId); } } /// public async ValueTask DisposeAsync() { if (_disposed) { return; } _disposed = true; GatewaySession? session = _session; _session = null; if (session is not null) { await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false); } _gate.Dispose(); } }