Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs
T
Joseph Doherty 6f4c7c3bf1
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (pull_request) Successful in 2m0s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m16s
ci / java (push) Successful in 2m19s
ci / portable (push) Failing after 2m31s
ci / portable (pull_request) Failing after 2m36s
ci / windows-x86 (push) Successful in 3m11s
Keep subtag acks and alarm reads safe on the managed ack path
Review fixes for the opt-in managed GUID ack:

- Subtag-mode GUID acks (forced subtag, or failover on the standby) stay on
  the consumer; their GUIDs are synthetic and only the subtag consumer can
  resolve them. Forced-subtag sessions no longer build a managed client.
- A failed managed Initialize/Register/Subscribe drops back to the wnwrap
  ack path instead of failing the alarm subscription.
- Drop stale crefs to a nonexistent Unverified member in
  WnalNativeAckConsumer.
- Unit tests for routing, fallback, and disposal; document
  MXGATEWAY_USE_MANAGED_ALARM_ACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:11:01 -04:00

423 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session owner of the worker's alarm-side state. Lazy-creates an
/// <see cref="AlarmDispatcher"/> (with a wnwrap-backed
/// <see cref="WnWrapAlarmConsumer"/> by default) on the first
/// <see cref="Subscribe"/> call, then routes
/// <see cref="Acknowledge"/> / <see cref="QueryActive"/> /
/// <see cref="Unsubscribe"/> through the same instance for the
/// session's lifetime.
/// </summary>
/// <remarks>
/// <para>
/// Construction is dependency-injectable: the consumer factory
/// (default <c>() =&gt; new WnWrapAlarmConsumer()</c>) lets tests
/// substitute a fake without touching AVEVA COM. The event queue
/// is supplied by the owning <see cref="MxAccessStaSession"/> so
/// the alarm-side proto events land on the same queue the worker
/// already drains for IPC dispatch.
/// </para>
/// <para>
/// Threading: invoked from <see cref="MxAccessCommandExecutor"/>
/// which runs on the STA. The wnwrap consumer owns no internal
/// timer — the worker's STA drives <see cref="PollOnce"/> via
/// <c>StaRuntime.InvokeAsync</c>, so the consumer's transition
/// events fire on the same STA. The
/// <see cref="AlarmDispatcher"/>'s event handler hands transitions
/// into the thread-safe <see cref="MxAccessEventQueue"/>.
/// </para>
/// </remarks>
public sealed class AlarmCommandHandler : IAlarmCommandHandler
{
private readonly MxAccessEventQueue eventQueue;
private readonly Func<IMxAccessAlarmConsumer> consumerFactory;
private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object();
private AlarmDispatcher? dispatcher;
private FailoverAlarmConsumer? failoverConsumer;
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
private string subscribeSessionId = string.Empty;
private string subscriptionExpression = string.Empty;
private bool disposed;
/// <summary>Initializes a new alarm command handler with the given event queue.</summary>
/// <param name="eventQueue">The event queue.</param>
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
{
}
/// <summary>Test seam — inject a custom consumer factory.</summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">The alarm consumer factory.</param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory)
: this(eventQueue, consumerFactory, threadAffinityCheck: null)
{
}
/// <summary>
/// Production constructor that also injects an
/// STA-affinity guard. <paramref name="threadAffinityCheck"/> is
/// invoked at the entry of every method that touches the underlying
/// <see cref="IMxAccessAlarmConsumer"/> (or the wnwrap COM object
/// through it) — <see cref="Subscribe"/>, <see cref="Unsubscribe"/>,
/// <see cref="Acknowledge"/>, <see cref="AcknowledgeByName"/>,
/// <see cref="QueryActive"/>, <see cref="PollOnce"/> — so an
/// off-STA call raises a programming-error diagnostic instead of
/// deadlocking on cross-apartment marshaling to the
/// <c>ThreadingModel=Apartment</c> wnwrap CLSID. The guard is
/// optional: tests that already drive the handler on a single
/// thread can pass <c>null</c>.
/// </summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">The alarm consumer factory.</param>
/// <param name="threadAffinityCheck">Optional thread affinity check action.</param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck)
: this(eventQueue, consumerFactory, threadAffinityCheck, comFactory: null, standbyFactory: null)
{
}
/// <summary>
/// Full constructor that also threads the MXAccess COM-object factory and
/// an optional standby-consumer seam so the subscribe path can build the
/// subtag / failover consumers required by
/// <see cref="SubscribeAlarmsCommand.ForcedMode"/> and
/// <see cref="SubscribeAlarmsCommand.WatchList"/>.
/// </summary>
/// <param name="eventQueue">The event queue.</param>
/// <param name="consumerFactory">
/// Factory for the PRIMARY (alarmmgr) consumer — the existing
/// wnwrap-backed source. Used alone in alarmmgr mode and as the primary
/// of the failover composite in auto mode.
/// </param>
/// <param name="threadAffinityCheck">Optional STA thread-affinity guard.</param>
/// <param name="comFactory">
/// The MXAccess COM-object factory used to build the
/// <see cref="LmxSubtagAlarmSource"/> backing the subtag consumer. May be
/// <see langword="null"/> when a <paramref name="standbyFactory"/> is
/// supplied (tests) or when only the alarmmgr path is ever exercised.
/// </param>
/// <param name="standbyFactory">
/// Optional seam that builds the STANDBY (subtag) consumer from a watch
/// list. Defaults to a <see cref="SubtagAlarmConsumer"/> over an
/// <see cref="LmxSubtagAlarmSource"/> built from
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory.
/// </param>
/// <param name="managedAckFactory">
/// Optional per-subscription factory for a managed ack client
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
/// client, GUID-keyed acknowledges route through AVEVA's modern
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
/// </param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
this.threadAffinityCheck = threadAffinityCheck;
this.comFactory = comFactory;
this.standbyFactory = standbyFactory;
this.managedAckFactory = managedAckFactory;
}
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
public bool IsSubscribed
{
get { lock (syncRoot) return dispatcher is not null; }
}
/// <inheritdoc />
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (command is null) throw new ArgumentNullException(nameof(command));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is not null)
{
throw new InvalidOperationException(
"AlarmCommandHandler already has an active subscription; " +
"call Unsubscribe before issuing another SubscribeAlarms command.");
}
subscribeSessionId = sessionId ?? string.Empty;
subscriptionExpression = command.SubscriptionExpression ?? string.Empty;
IMxAccessAlarmConsumer consumer = BuildConsumer(command)
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
// When the selected consumer is a failover composite, surface its
// provider switches onto the worker's event queue so connected
// gateway clients can observe degraded/recovered state. The handler
// is unsubscribed/disposed on Unsubscribe/Dispose below.
if (consumer is FailoverAlarmConsumer failover)
{
failoverConsumer = failover;
providerModeChangedHandler = OnProviderModeChanged;
failover.ProviderModeChanged += providerModeChangedHandler;
}
// Optional managed ack client (opt-in via env); null → wnwrap ack.
// A forced-subtag session never has alarmmgr GUIDs to ack, so it
// skips the extra managed consumer entirely.
IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag
? null
: managedAckFactory?.Invoke();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck);
try
{
dispatcher.Subscribe(subscriptionExpression);
}
catch
{
DetachProviderModeChanged();
try { dispatcher.Dispose(); } catch { /* swallow */ }
dispatcher = null;
throw;
}
}
}
/// <summary>
/// Selects and builds the alarm consumer from the command's
/// <see cref="SubscribeAlarmsCommand.ForcedMode"/> and
/// <see cref="SubscribeAlarmsCommand.WatchList"/>:
/// <list type="bullet">
/// <item><description>
/// <c>Alarmmgr</c>, or <c>Unspecified</c> with an empty watch
/// list: the existing primary (alarmmgr) consumer only —
/// today's behavior.
/// </description></item>
/// <item><description>
/// <c>Subtag</c>: a <see cref="SubtagAlarmConsumer"/> only.
/// </description></item>
/// <item><description>
/// <c>Unspecified</c> with a non-empty watch list (auto): a
/// <see cref="FailoverAlarmConsumer"/> over the primary and a
/// subtag standby.
/// </description></item>
/// </list>
/// </summary>
private IMxAccessAlarmConsumer BuildConsumer(SubscribeAlarmsCommand command)
{
List<AlarmSubtagTarget> watchList = new List<AlarmSubtagTarget>(command.WatchList);
if (command.ForcedMode == AlarmProviderMode.Subtag)
{
return BuildStandby(watchList);
}
if (command.ForcedMode == AlarmProviderMode.Unspecified
&& (command.FallbackEnabled || watchList.Count > 0))
{
IMxAccessAlarmConsumer primary = consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
IMxAccessAlarmConsumer standby = BuildStandby(watchList);
AlarmFailoverConfig? failoverConfig = command.Failover;
FailoverSettings settings = new FailoverSettings(
failoverConfig?.ConsecutiveFailureThreshold ?? 3,
failoverConfig?.FailbackProbeIntervalSeconds ?? 30,
failoverConfig?.FailbackStableProbes ?? 3);
return new FailoverAlarmConsumer(primary, standby, settings);
}
// Alarmmgr, or Unspecified with an empty watch list — primary only.
return consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
}
/// <inheritdoc />
public void UpdateWatchList(IReadOnlyList<AlarmSubtagTarget> watchList)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (watchList is null) throw new ArgumentNullException(nameof(watchList));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is null || failoverConsumer is null)
{
throw new InvalidOperationException(
"UpdateAlarmWatchList requires an active fallback-capable alarm subscription.");
}
IMxAccessAlarmConsumer replacement = BuildStandby(watchList);
failoverConsumer.ReplaceStandby(replacement, subscriptionExpression);
}
}
private IMxAccessAlarmConsumer BuildStandby(IReadOnlyList<AlarmSubtagTarget> watchList)
{
if (standbyFactory is not null)
{
return standbyFactory(watchList)
?? throw new InvalidOperationException("Standby alarm consumer factory returned null.");
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Subtag alarm consumer requires an IMxAccessComObjectFactory; the alarm command "
+ "handler was constructed without one and no standby factory was supplied.");
}
return new SubtagAlarmConsumer(new LmxSubtagAlarmSource(comFactory), watchList);
}
private void OnProviderModeChanged(object? sender, AlarmProviderModeChange change)
{
if (change is null) return;
eventQueue.Enqueue(mapper.CreateOnAlarmProviderModeChanged(
subscribeSessionId, change.Mode, change.Reason, change.HResult, change.AtUtc));
}
private void DetachProviderModeChanged()
{
if (failoverConsumer is not null && providerModeChangedHandler is not null)
{
try { failoverConsumer.ProviderModeChanged -= providerModeChangedHandler; }
catch { /* swallow */ }
}
failoverConsumer = null;
providerModeChangedHandler = null;
}
/// <inheritdoc />
public void Unsubscribe()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? toDispose;
lock (syncRoot)
{
DetachProviderModeChanged();
toDispose = dispatcher;
dispatcher = null;
subscriptionExpression = string.Empty;
}
toDispose?.Dispose();
}
/// <inheritdoc />
public int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.Acknowledge(
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
string? alarmFilterPrefix,
out bool snapshotTruncated)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
// The verdict rides out of the same call that produced the records, so
// filtering below cannot separate it from the set it describes.
IReadOnlyList<ActiveAlarmSnapshot> all = d.SnapshotActiveAlarms(out snapshotTruncated);
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
List<ActiveAlarmSnapshot> filtered = new List<ActiveAlarmSnapshot>(all.Count);
foreach (ActiveAlarmSnapshot snap in all)
{
if (snap.AlarmFullReference.StartsWith(alarmFilterPrefix!, StringComparison.Ordinal))
{
filtered.Add(snap);
}
}
return filtered;
}
/// <inheritdoc />
public void PollOnce()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
// No-op when not yet subscribed or already disposed.
d?.PollOnce();
}
private AlarmDispatcher GetDispatcherOrThrow()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
if (d is null)
{
throw new InvalidOperationException(
"AlarmCommandHandler has no active subscription; " +
"call SubscribeAlarms before issuing alarm-related commands.");
}
return d;
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
Unsubscribe();
}
}