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
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>
423 lines
18 KiB
C#
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>() => 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();
|
|
}
|
|
}
|