rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDashboardLiveDataService"/>. 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 <see cref="_gate"/> so the
|
||||
/// single backing worker only ever sees one in-flight command.
|
||||
/// </summary>
|
||||
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<DashboardLiveDataService> _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private GatewaySession? _session;
|
||||
private int _serverHandle;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes the live-data service.</summary>
|
||||
/// <param name="sessionManager">Gateway session manager.</param>
|
||||
/// <param name="alarmService">Gateway central alarm service.</param>
|
||||
/// <param name="logger">Diagnostic logger.</param>
|
||||
public DashboardLiveDataService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayAlarmService alarmService,
|
||||
ILogger<DashboardLiveDataService> logger)
|
||||
{
|
||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||
_alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DashboardLiveReadResult> ReadAsync(
|
||||
IReadOnlyCollection<string> 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<BulkReadResult> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DashboardAlarmQueryResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user