9409488008
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
911 lines
43 KiB
C#
911 lines
43 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Runtime.InteropServices;
|
|
using System.Xml;
|
|
using WNWRAPCONSUMERLib;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="IMxAccessAlarmConsumer"/> backed by AVEVA's
|
|
/// standalone <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> COM object
|
|
/// (CLSID <c>{7AB52E5F-36B2-4A30-AE46-952A746F667C}</c>, hosted by
|
|
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Replaces the earlier <c>AlarmClientConsumer</c> built on
|
|
/// <c>aaAlarmManagedClient.AlarmClient</c>, which crashed in
|
|
/// <c>GetHighPriAlarm</c> with <c>ArgumentOutOfRangeException</c>
|
|
/// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps).
|
|
/// The wnwrap surface returns the alarm record as a BSTR XML string
|
|
/// via <c>GetXmlCurrentAlarms2</c>; timestamps arrive as ASCII
|
|
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
|
/// fields and never touch the .NET DateTime marshaler. See
|
|
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" for
|
|
/// the discovery and the captured payload schema.
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Threading.</strong> The wnwrap CLSID is registered with
|
|
/// <c>ThreadingModel=Apartment</c>. The consumer must be created
|
|
/// and operated from an STA thread; the worker's
|
|
/// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
|
|
/// The consumer owns <em>no</em> internal timer: every COM call
|
|
/// (<c>Subscribe</c>, <c>PollOnce</c>) must
|
|
/// be invoked on the STA that created the consumer. Polling cadence
|
|
/// is driven externally by the worker's STA via
|
|
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c>, which
|
|
/// keeps every <c>GetXmlCurrentAlarms2</c> call on the apartment that
|
|
/// owns the COM object. A thread-pool timer would call the COM API
|
|
/// off the owning STA and can deadlock on cross-apartment marshaling
|
|
/// when the STA is not pumping messages, so no such timer exists.
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Acknowledgement.</strong> This consumer only receives
|
|
/// alarms. The wnwrap <c>AlarmAckBy*</c> calls were accepted and then
|
|
/// inert on the dev rig (see <c>docs/AlarmProbeFindings.md</c>), so
|
|
/// acknowledgement no longer goes through wnwrap: the consumer resolves
|
|
/// a Galaxy alarm to its <c><tag>.AckMsg</c> item
|
|
/// (<see cref="ResolveAckTarget"/>) and <see cref="AlarmAckWriter"/>
|
|
/// writes that item over MXAccess as the authenticated operator.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|
{
|
|
private const string DefaultProductName = "OtOpcUa.MxGateway";
|
|
private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
|
|
private const string DefaultVersion = "1.0";
|
|
private const int DefaultMaxAlarmsPerFetch = 1024;
|
|
private const string GalaxyProviderName = "Galaxy";
|
|
|
|
/// <summary>
|
|
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side
|
|
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> minimum so a value that
|
|
/// slipped past startup validation still leaves a usable snapshot
|
|
/// window.
|
|
/// </summary>
|
|
internal const int MinimumMaxAlarmsPerFetch = 64;
|
|
|
|
/// <summary>
|
|
/// Ceiling on the resolved per-fetch cap. Mirrors the gateway-side
|
|
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> maximum. The worker is a
|
|
/// 32-bit process: every poll materializes the whole reply as one BSTR
|
|
/// and then a full <see cref="XmlDocument"/> over it, so an unbounded
|
|
/// cap (<see cref="int.MaxValue"/> passed startup validation before
|
|
/// this existed) is an out-of-memory fault on the STA, not a slow poll.
|
|
/// </summary>
|
|
internal const int MaximumMaxAlarmsPerFetch = 65_536;
|
|
|
|
/// <summary>
|
|
/// Environment variable the gateway's <c>WorkerProcessLauncher</c>
|
|
/// sets from <c>MxGateway:Alarms:MaxAlarmsPerFetch</c>. A missing,
|
|
/// unparseable, or out-of-range value falls back to
|
|
/// <see cref="DefaultMaxAlarmsPerFetch"/> — a bad environment value
|
|
/// must never keep the alarm consumer from starting.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Minimum gap between truncated-fetch warnings. A galaxy parked above
|
|
/// the cap truncates on *every* poll, so an unthrottled warning would
|
|
/// be two log lines a second forever.
|
|
/// </summary>
|
|
private const long TruncationWarningIntervalMilliseconds = 60_000;
|
|
|
|
private readonly object syncRoot = new object();
|
|
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
|
|
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
|
private readonly int maxAlarmsPerFetch;
|
|
private readonly Stopwatch truncationWarningClock = Stopwatch.StartNew();
|
|
|
|
private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
|
|
private long truncatedFetchCount;
|
|
private bool lastSnapshotTruncated;
|
|
private wwAlarmConsumerClass? client;
|
|
private bool subscribed;
|
|
private bool disposed;
|
|
|
|
/// <summary>
|
|
/// Production constructor — creates the wnwrap COM object on the
|
|
/// current thread (which must be the worker's STA). Polling is driven
|
|
/// externally by the STA via
|
|
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c> so that
|
|
/// every COM call stays on the STA that owns the apartment.
|
|
/// </summary>
|
|
public WnWrapAlarmConsumer()
|
|
: this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test seam / explicit construction.
|
|
/// </summary>
|
|
/// <param name="client">The COM alarm consumer instance.</param>
|
|
/// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
|
|
public WnWrapAlarmConsumer(
|
|
wwAlarmConsumerClass client,
|
|
int maxAlarmsPerFetch)
|
|
{
|
|
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
|
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
|
? maxAlarmsPerFetch
|
|
: DefaultMaxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// COM-free construction, for exercising the retained-snapshot state
|
|
/// machine (<see cref="FoldFetch"/> / <see cref="SnapshotActiveAlarms"/>)
|
|
/// on a machine without AVEVA
|
|
/// installed. <see cref="Subscribe"/> throws and <see cref="PollOnce"/>
|
|
/// no-ops on an instance built this way — both need the wnwrap coclass,
|
|
/// which cannot be instantiated on the macOS/Linux test matrix. Internal
|
|
/// rather than public so it cannot be reached from production wiring.
|
|
/// </summary>
|
|
/// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
|
|
internal WnWrapAlarmConsumer(int maxAlarmsPerFetch)
|
|
{
|
|
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
|
? maxAlarmsPerFetch
|
|
: DefaultMaxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the per-fetch cap from the launcher-provided environment
|
|
/// variable. A missing, unparseable, or out-of-range value falls back
|
|
/// to <see cref="DefaultMaxAlarmsPerFetch"/> rather than throwing:
|
|
/// polling at the default cap is always safe, and a session that
|
|
/// refuses to subscribe over a mistyped environment variable is not.
|
|
/// The ceiling is enforced here as well as in the gateway validator so
|
|
/// a hand-set environment (or a future launcher bug) cannot hand the
|
|
/// x86 worker a cap that faults it on the first poll.
|
|
/// </summary>
|
|
/// <returns>The cap passed to <c>GetXmlCurrentAlarms2</c>.</returns>
|
|
internal static int ResolveMaxAlarmsPerFetch()
|
|
{
|
|
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.MaxAlarmsPerFetch);
|
|
return int.TryParse(
|
|
value,
|
|
NumberStyles.Integer,
|
|
CultureInfo.InvariantCulture,
|
|
out int cap)
|
|
&& cap >= MinimumMaxAlarmsPerFetch
|
|
&& cap <= MaximumMaxAlarmsPerFetch
|
|
? cap
|
|
: DefaultMaxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
|
/// clear, or new-alarm-already-acked-on-arrival), dispatched from
|
|
/// <see cref="PollOnce"/> on the calling (STA) thread.
|
|
/// </summary>
|
|
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
|
|
|
/// <inheritdoc />
|
|
public void Subscribe(string subscription)
|
|
{
|
|
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
lock (syncRoot)
|
|
{
|
|
if (subscribed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"WnWrapAlarmConsumer.Subscribe was called more than once; " +
|
|
"wwAlarmConsumerClass.Subscribe replaces the previous filter and is not idempotent.");
|
|
}
|
|
|
|
wwAlarmConsumerClass com = client
|
|
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
|
|
// lifecycle. Empirically (live dev-rig 2026-05-01, reconfirmed
|
|
// 2026-08-18) the v1 path is the one that leaves the consumer in
|
|
// a usable state: the v2 Initialize/Register/Subscribe methods on
|
|
// the class succeed (return 0) but later calls against that
|
|
// consumer state return -55. The v1 prefix path is what
|
|
// WIN-911-style code uses against the same wnwrap library. This
|
|
// consumer only reads the alarm snapshot; acknowledgements go
|
|
// through AlarmAckWriter, not through wnwrap (see
|
|
// docs/AlarmProbeFindings.md).
|
|
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
|
|
if (init != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.InitializeConsumer returned non-zero status {init}.");
|
|
}
|
|
|
|
// hWnd=0: wnwrap supports a pull-based model — no message pump
|
|
// is required. GetXmlCurrentAlarms2 is polled by the worker's STA
|
|
// via StaRuntime.InvokeAsync(() => consumer.PollOnce()); this type
|
|
// owns no internal timer.
|
|
int reg = com.IwwAlarmConsumer_RegisterConsumer(
|
|
hWnd: 0,
|
|
szProductName: DefaultProductName,
|
|
szApplicationName: DefaultApplicationName,
|
|
szVersion: DefaultVersion);
|
|
if (reg != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
|
|
}
|
|
|
|
int sub = com.IwwAlarmConsumer_Subscribe(
|
|
szSubscription: subscription,
|
|
wFromPri: 1,
|
|
wToPri: 999,
|
|
QueryType: eQueryType.qtSummary,
|
|
SortFlags: eSortFlags.sfReturnNewestFirst,
|
|
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
|
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
|
if (sub != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
|
|
}
|
|
|
|
// Empirically required: even though the round-trip echo of
|
|
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
|
|
// calling it is necessary for subsequent GetXmlCurrentAlarms2
|
|
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
|
|
// E_FAIL (HRESULT 0x80004005) on the first poll.
|
|
//
|
|
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
|
|
// — there is no integer return code to gate on like the other v1
|
|
// lifecycle calls in this method. A genuine failure surfaces as a
|
|
// COM exception (mapped from the underlying HRESULT). Wrap the
|
|
// call so a failure becomes an InvalidOperationException with
|
|
// diagnostic context, matching the other call-gates' failure
|
|
// shape rather than letting an opaque COMException escape with
|
|
// no indication that the alarm subscription is now misconfigured
|
|
// and the next GetXmlCurrentAlarms2 poll will fail with E_FAIL.
|
|
string xmlQuery = ComposeXmlAlarmQuery(subscription);
|
|
try
|
|
{
|
|
com.SetXmlAlarmQuery(xmlQuery);
|
|
}
|
|
catch (COMException ex)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.SetXmlAlarmQuery failed with HRESULT 0x{ex.HResult:X8}; " +
|
|
"subsequent GetXmlCurrentAlarms2 polls would return E_FAIL.",
|
|
ex);
|
|
}
|
|
|
|
subscribed = true;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
MxAlarmSnapshotRecord? record;
|
|
lock (syncRoot)
|
|
{
|
|
latestSnapshot.TryGetValue(alarmGuid, out record);
|
|
}
|
|
|
|
if (record is null)
|
|
{
|
|
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
|
|
}
|
|
|
|
return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
return ResolveGalaxyAckTarget(providerName, alarmName);
|
|
}
|
|
|
|
private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
|
|
{
|
|
if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return AlarmAckTarget.NotAckable(
|
|
$"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tagName))
|
|
{
|
|
return AlarmAckTarget.NotFound("Alarm record has no tag name.");
|
|
}
|
|
|
|
return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
lock (syncRoot)
|
|
{
|
|
List<MxAlarmSnapshotRecord> active = new List<MxAlarmSnapshotRecord>();
|
|
foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values)
|
|
{
|
|
if (record.State == MxAlarmStateKind.UnackAlm
|
|
|| record.State == MxAlarmStateKind.AckAlm)
|
|
{
|
|
active.Add(record);
|
|
}
|
|
}
|
|
truncated = lastSnapshotTruncated;
|
|
return active;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sink for the rate-limited truncated-fetch warning. Defaults to
|
|
/// <see cref="Console.Error"/>, the stream
|
|
/// <c>WorkerConsoleLogger</c> already writes to, so the line lands in
|
|
/// the worker's captured stderr alongside the rest of its log. Tests
|
|
/// substitute a collector. The message carries identifiers and counts
|
|
/// only — never alarm values, tag names, or comments.
|
|
/// </summary>
|
|
internal Action<string>? TruncationWarningSink { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public void PollOnce()
|
|
{
|
|
wwAlarmConsumerClass? com;
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed || !subscribed) return;
|
|
com = client;
|
|
}
|
|
if (com is null) return;
|
|
|
|
object xmlObj = string.Empty;
|
|
com.GetXmlCurrentAlarms2(maxAlmCnt: maxAlarmsPerFetch, vartCurrentXmlAlarms: out xmlObj);
|
|
string xml = xmlObj?.ToString() ?? string.Empty;
|
|
if (xml.Length == 0) return;
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
|
|
|
|
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
|
|
// and gives no *verified* "there is more" flag, so a reply holding
|
|
// exactly the cap is indistinguishable from a galaxy that happens to
|
|
// have exactly that many active alarms. Treat the ambiguous case as
|
|
// truncated: the false-positive cost is a snapshot that stays stale for
|
|
// one poll, the false-negative cost is every alarm past the cap reading
|
|
// as cleared. (The reply's ALARM_RECORDS/@COUNT attribute was the
|
|
// candidate exact signal; the 2026-08-18 live probe settled it — with
|
|
// three alarms active, @COUNT read 3, 1, and 2 at caps 1024, 1, and 2,
|
|
// matching the records in the reply every time. It reports the reply,
|
|
// not the total, so it cannot tell a capped fetch from a complete one.
|
|
// See docs/AlarmProbeFindings.md.)
|
|
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
FoldFetch(next, truncated, out int retainedCount);
|
|
|
|
if (truncated)
|
|
{
|
|
WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount);
|
|
}
|
|
|
|
if (transitions.Count == 0) return;
|
|
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
|
|
if (handler is null) return;
|
|
foreach (MxAlarmTransitionEvent transition in transitions)
|
|
{
|
|
handler.Invoke(this, transition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Folds one fetch into the retained state under a single lock: the
|
|
/// transition diff, the snapshot merge/replace, and the truncation
|
|
/// verdict move together. This is the write half of the pairing
|
|
/// <see cref="SnapshotActiveAlarms"/> reads; splitting either half would
|
|
/// let a reader see a capped snapshot alongside the previous poll's
|
|
/// "complete" verdict — precisely the false all-clear the signal exists
|
|
/// to prevent.
|
|
/// The verdict is replaced, never latched: a sub-cap fetch is complete
|
|
/// and restores absence authority, so leaving the flag set would strand
|
|
/// the operator banner on after a single burst.
|
|
/// </summary>
|
|
/// <param name="next">The snapshot just parsed from the fetch.</param>
|
|
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
|
|
/// <param name="retainedCount">Size of the retained snapshot after the fold.</param>
|
|
/// <returns>The transitions the fetch implies.</returns>
|
|
internal IReadOnlyList<MxAlarmTransitionEvent> FoldFetch(
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next,
|
|
bool truncated,
|
|
out int retainedCount)
|
|
{
|
|
lock (syncRoot)
|
|
{
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
ComputeTransitions(latestSnapshot, next);
|
|
ApplySnapshotUpdate(latestSnapshot, next, truncated);
|
|
lastSnapshotTruncated = truncated;
|
|
retainedCount = latestSnapshot.Count;
|
|
return transitions;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decides whether a fetch that came back holding
|
|
/// <paramref name="fetchedRecordCount"/> records hit the cap.
|
|
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
|
|
/// offers no confirmed "more available" flag, so a reply at exactly the
|
|
/// cap is indistinguishable from a galaxy that happens to hold exactly
|
|
/// that many active alarms; both are treated as truncated. The reply
|
|
/// root carries an <c>ALARM_RECORDS/@COUNT</c> attribute that would have
|
|
/// made the test exact had it reported the total active count; the
|
|
/// 2026-08-18 live probe observed it tracking the records in the reply
|
|
/// instead (three alarms active returned @COUNT 1 at cap 1 and 2 at cap
|
|
/// 2), so it carries no "more available" information and is deliberately
|
|
/// not trusted here (see <c>docs/AlarmProbeFindings.md</c>). Exposed as
|
|
/// <c>internal static</c> so the rule is unit-testable without the
|
|
/// wnwrapConsumer COM object.
|
|
/// </summary>
|
|
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
|
|
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
|
|
/// <returns><see langword="true"/> when the reply must be treated as truncated.</returns>
|
|
internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch)
|
|
{
|
|
return fetchedRecordCount >= maxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Folds a freshly fetched snapshot into the retained one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A complete fetch is authoritative about <em>absence</em>:
|
|
/// replacing the snapshot wholesale is exactly what lets a cleared
|
|
/// alarm drop out of <see cref="SnapshotActiveAlarms"/>, which is
|
|
/// the evidence the gateway's reconcile pass turns into a Clear.
|
|
/// </para>
|
|
/// <para>
|
|
/// A truncated fetch is authoritative about <em>presence</em>
|
|
/// only. Merging rather than replacing means alarms the capped
|
|
/// reply did carry update normally, while alarms it had no room to
|
|
/// mention are retained untouched — so the gateway infers no Clear
|
|
/// from their absence. The cost is bounded staleness: a genuinely
|
|
/// cleared alarm can linger in the snapshot until the first
|
|
/// sub-cap fetch evicts it. That direction is the safe one for an
|
|
/// at-least-once alarm feed — a late Clear is repaired by the next
|
|
/// complete poll, whereas a Clear that never happened is broadcast
|
|
/// to every subscriber and cannot be taken back.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="snapshot">The retained snapshot, updated in place.</param>
|
|
/// <param name="next">The snapshot just parsed from the fetch.</param>
|
|
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
|
|
internal static void ApplySnapshotUpdate(
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot,
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next,
|
|
bool truncated)
|
|
{
|
|
if (snapshot is null) throw new ArgumentNullException(nameof(snapshot));
|
|
if (next is null) throw new ArgumentNullException(nameof(next));
|
|
|
|
if (!truncated)
|
|
{
|
|
snapshot.Clear();
|
|
}
|
|
|
|
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
|
{
|
|
snapshot[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the truncated-fetch warning at most once per
|
|
/// <see cref="TruncationWarningIntervalMilliseconds"/>, counting every
|
|
/// truncated poll in between so the operator can tell a one-off burst
|
|
/// from a galaxy permanently parked above the cap.
|
|
/// </summary>
|
|
/// <param name="fetchedRecordCount">ALARM records the capped reply carried.</param>
|
|
/// <param name="parsedRecordCount">Records that survived GUID parsing.</param>
|
|
/// <param name="retainedCount">Snapshot size after the merge.</param>
|
|
private void WarnTruncatedFetch(int fetchedRecordCount, int parsedRecordCount, int retainedCount)
|
|
{
|
|
long total;
|
|
long elapsed;
|
|
lock (syncRoot)
|
|
{
|
|
total = ++truncatedFetchCount;
|
|
elapsed = truncationWarningClock.ElapsedMilliseconds;
|
|
if (!ShouldWarnTruncation(elapsed, lastTruncationWarningMilliseconds))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lastTruncationWarningMilliseconds = elapsed;
|
|
}
|
|
|
|
// Format matches WorkerConsoleLogger's "level=<L> event=<E> k=v k=v"
|
|
// line shape so the worker's stderr parses uniformly. Deviation, on
|
|
// purpose: IWorkerLogger exposes only Information and Error, so
|
|
// "Warning" is a third level string no other worker line emits. A
|
|
// truncated snapshot is not an error (the poll succeeded and the
|
|
// snapshot is safe) but it is not routine either, so downgrading it to
|
|
// Information would bury it. Revisit if the truncation signal becomes
|
|
// structural — see docs/DesignDecisions.md.
|
|
//
|
|
// Identifiers and counts only — no tag names, values, limits, or
|
|
// comments, per the gateway's "don't log tag values by default" rule.
|
|
// The trailing note stays a single bare k=v token (no spaces, no
|
|
// semicolons); remediation prose lives in docs/GatewayConfiguration.md.
|
|
string message = string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"level=Warning event=AlarmSnapshotTruncated maxAlarmsPerFetch={0} fetchedRecords={1} "
|
|
+ "parsedRecords={2} retainedSnapshotSize={3} truncatedFetchesSinceStart={4} "
|
|
+ "note=truncated-snapshot-retained",
|
|
maxAlarmsPerFetch,
|
|
fetchedRecordCount,
|
|
parsedRecordCount,
|
|
retainedCount,
|
|
total);
|
|
|
|
Action<string> sink = TruncationWarningSink ?? Console.Error.WriteLine;
|
|
sink(message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rate-limit decision for the truncated-fetch warning: emit only when
|
|
/// a full <see cref="TruncationWarningIntervalMilliseconds"/> has
|
|
/// elapsed since the last one. A galaxy parked above the cap truncates
|
|
/// on every poll, so consecutive truncated polls inside one interval
|
|
/// must produce exactly one line. Exposed as <c>internal static</c>
|
|
/// so the throttle is unit-testable without the wnwrapConsumer COM
|
|
/// object.
|
|
/// </summary>
|
|
/// <param name="elapsedMilliseconds">Consumer-lifetime clock reading for this poll.</param>
|
|
/// <param name="lastWarningMilliseconds">Clock reading when the last warning was emitted.</param>
|
|
/// <returns><see langword="true"/> when this poll may emit a warning.</returns>
|
|
internal static bool ShouldWarnTruncation(
|
|
long elapsedMilliseconds,
|
|
long lastWarningMilliseconds)
|
|
{
|
|
return elapsedMilliseconds - lastWarningMilliseconds >= TruncationWarningIntervalMilliseconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure snapshot-to-transitions diff. Compares the previous polled
|
|
/// snapshot to the next snapshot and produces one
|
|
/// <see cref="MxAlarmTransitionEvent"/> per state change. Used by
|
|
/// <see cref="PollOnce"/> after a successful
|
|
/// <c>GetXmlCurrentAlarms2</c> call; exposed as <c>internal static</c>
|
|
/// so the diff rules can be unit-tested without driving the
|
|
/// wnwrapConsumer COM object.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Rules:</para>
|
|
/// <list type="bullet">
|
|
/// <item><description>A GUID present in <paramref name="next"/> but not in <paramref name="previous"/> produces a transition with <see cref="MxAlarmStateKind.Unspecified"/> as the previous state — first sighting.</description></item>
|
|
/// <item><description>A GUID present in both with the same <see cref="MxAlarmSnapshotRecord.State"/> produces no transition.</description></item>
|
|
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
|
|
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// The absence rule is deliberately inert here: this diff never
|
|
/// turns a disappearance into a Clear. The clear is inferred one
|
|
/// level up, by the gateway's reconcile pass diffing its cache
|
|
/// against <see cref="SnapshotActiveAlarms"/>, which reads the
|
|
/// snapshot <see cref="PollOnce"/> maintains from these same
|
|
/// dictionaries. That is why the truncation guard lives in
|
|
/// <see cref="PollOnce"/>'s snapshot update rather than in this
|
|
/// method: suppressing "absence implies Clear" means declining to
|
|
/// *evict* alarms a capped fetch could not mention, not declining
|
|
/// to emit a transition this method was never going to emit.
|
|
/// Passing a truncated <paramref name="next"/> straight through
|
|
/// stays correct for the alarms it does carry — first sightings
|
|
/// and state changes are computed from presence alone.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every rule above assumes the GUID identifies the alarm
|
|
/// <em>instance</em> rather than the state it is in: a re-minted
|
|
/// GUID would read as the old alarm vanishing and a new one
|
|
/// appearing, i.e. a spurious Clear plus a spurious Raise. Live
|
|
/// capture confirms stability across the active→returned leg
|
|
/// (<c>docs/AlarmClientDiscovery.md</c>, reconfirmed 2026-08-18).
|
|
/// Re-raise-after-clear was observed on 2026-08-18 and does
|
|
/// <em>not</em> reuse the GUID: clearing left the returned record in
|
|
/// place under its original GUID and the re-raise replaced it with a
|
|
/// freshly minted one. That is the intended reading — the old
|
|
/// instance ended and a new one began — so the diff emits a Clear
|
|
/// plus a Raise, which is what happened. Every alarm-client ack surface
|
|
/// is inert on the dev rig (it returns 0 and nothing moves), so
|
|
/// acknowledgements go through the AckMsg writer; an AckMsg write
|
|
/// moves the record to <c>ACK_ALM</c> (observed 2026-10-19) — see
|
|
/// <c>docs/AlarmProbeFindings.md</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
|
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
|
/// <returns>One transition per state change in <paramref name="next"/>.</returns>
|
|
internal static IReadOnlyList<MxAlarmTransitionEvent> ComputeTransitions(
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous,
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next)
|
|
{
|
|
if (previous is null) throw new ArgumentNullException(nameof(previous));
|
|
if (next is null) throw new ArgumentNullException(nameof(next));
|
|
|
|
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
|
|
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
|
{
|
|
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
|
|
if (previous.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
|
|
{
|
|
previousState = prev.State;
|
|
if (previousState == kv.Value.State) continue; // no transition
|
|
}
|
|
transitions.Add(new MxAlarmTransitionEvent
|
|
{
|
|
Record = kv.Value,
|
|
PreviousState = previousState,
|
|
});
|
|
}
|
|
return transitions;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
|
|
/// into a GUID-keyed dictionary. Records with malformed GUIDs are
|
|
/// silently dropped (no fault is recorded — the next poll will
|
|
/// resync).
|
|
/// </summary>
|
|
/// <param name="xml">The XML snapshot payload.</param>
|
|
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
|
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
|
|
{
|
|
return ParseSnapshotXml(xml, out _);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="ParseSnapshotXml(string)"/> plus the raw
|
|
/// <c>ALARM</c>-element count, which <see cref="PollOnce"/> compares
|
|
/// against the per-fetch cap to detect a truncated reply. The raw
|
|
/// count — not the dictionary size — is the right signal: records
|
|
/// dropped for a malformed GUID still consumed a slot in the capped
|
|
/// reply, so counting only the survivors would under-report and let a
|
|
/// truncated fetch pass as complete.
|
|
/// </summary>
|
|
/// <param name="xml">The XML snapshot payload.</param>
|
|
/// <param name="alarmRecordCount">Number of <c>ALARM</c> elements in the payload.</param>
|
|
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
|
internal static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(
|
|
string xml,
|
|
out int alarmRecordCount)
|
|
{
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
|
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
|
alarmRecordCount = 0;
|
|
|
|
if (string.IsNullOrWhiteSpace(xml)) return records;
|
|
|
|
XmlDocument doc = new XmlDocument();
|
|
doc.LoadXml(xml);
|
|
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
|
|
if (alarmNodes is null) return records;
|
|
|
|
alarmRecordCount = alarmNodes.Count;
|
|
|
|
foreach (XmlNode alarmNode in alarmNodes)
|
|
{
|
|
// One pass over the child elements instead of 17 SelectSingleNode
|
|
// XPath evaluations per record. At the 1024-record cap that is the
|
|
// difference between ~17k XPath expression compilations plus child
|
|
// walks and one walk per record, on the STA that also serves reads
|
|
// and writes.
|
|
//
|
|
// Parity with the XPath it replaces: SelectSingleNode(name) picks
|
|
// the FIRST child ELEMENT of that name (ordinal, case-sensitive)
|
|
// and yields its InnerText; an absent child yields string.Empty,
|
|
// and so does a present-but-empty one. Hence: element nodes only,
|
|
// first-match-wins (null means "not seen yet"), and a single
|
|
// coalesce to string.Empty at the end — which keeps the absent and
|
|
// empty cases indistinguishable exactly as before.
|
|
//
|
|
// One theoretical divergence: XmlNode.Name is the QName, so a child
|
|
// carrying its own default-namespace declaration (<GUID xmlns="...">)
|
|
// matches here where the unprefixed XPath name test would not. It is
|
|
// theoretical — wnwrap emits no namespaces, and a namespaced ALARM
|
|
// wrapper makes the outer SelectNodes return nothing either way — and
|
|
// it errs permissive (field populated rather than silently dropped).
|
|
string? guidHex = null;
|
|
string? xmlDate = null;
|
|
string? xmlTime = null;
|
|
string? gmtOffset = null;
|
|
string? dstAdjust = null;
|
|
string? providerNode = null;
|
|
string? providerName = null;
|
|
string? group = null;
|
|
string? tagName = null;
|
|
string? type = null;
|
|
string? value = null;
|
|
string? limit = null;
|
|
string? priority = null;
|
|
string? state = null;
|
|
string? operatorNode = null;
|
|
string? operatorName = null;
|
|
string? alarmComment = null;
|
|
string? ackComment = null;
|
|
string? ackOperatorName = null;
|
|
string? ackOperatorDomain = null;
|
|
string? ackOperatorNode = null;
|
|
|
|
foreach (XmlNode child in alarmNode.ChildNodes)
|
|
{
|
|
if (child.NodeType != XmlNodeType.Element) continue;
|
|
|
|
switch (child.Name)
|
|
{
|
|
case "GUID": guidHex ??= child.InnerText; break;
|
|
case "DATE": xmlDate ??= child.InnerText; break;
|
|
case "TIME": xmlTime ??= child.InnerText; break;
|
|
case "GMTOFFSET": gmtOffset ??= child.InnerText; break;
|
|
case "DSTADJUST": dstAdjust ??= child.InnerText; break;
|
|
case "PROVIDER_NODE": providerNode ??= child.InnerText; break;
|
|
case "PROVIDER_NAME": providerName ??= child.InnerText; break;
|
|
case "GROUP": group ??= child.InnerText; break;
|
|
case "TAGNAME": tagName ??= child.InnerText; break;
|
|
case "TYPE": type ??= child.InnerText; break;
|
|
case "VALUE": value ??= child.InnerText; break;
|
|
case "LIMIT": limit ??= child.InnerText; break;
|
|
case "PRIORITY": priority ??= child.InnerText; break;
|
|
case "STATE": state ??= child.InnerText; break;
|
|
case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
|
|
case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
|
|
case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
|
|
case "ACK_COMMENT": ackComment ??= child.InnerText; break;
|
|
case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break;
|
|
case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break;
|
|
case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break;
|
|
}
|
|
}
|
|
|
|
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
|
|
|
|
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
|
xmlDate ?? string.Empty,
|
|
xmlTime ?? string.Empty,
|
|
ParseInt(gmtOffset ?? string.Empty),
|
|
ParseInt(dstAdjust ?? string.Empty));
|
|
|
|
records[guid] = new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = guid,
|
|
TransitionTimestampUtc = tsUtc,
|
|
ProviderNode = providerNode ?? string.Empty,
|
|
ProviderName = providerName ?? string.Empty,
|
|
Group = group ?? string.Empty,
|
|
TagName = tagName ?? string.Empty,
|
|
Type = type ?? string.Empty,
|
|
Value = value ?? string.Empty,
|
|
Limit = limit ?? string.Empty,
|
|
Priority = ParseInt(priority ?? string.Empty),
|
|
State = AlarmRecordTransitionMapper.ParseStateKind(state ?? string.Empty),
|
|
OperatorNode = operatorNode ?? string.Empty,
|
|
OperatorName = operatorName ?? string.Empty,
|
|
AlarmComment = alarmComment ?? string.Empty,
|
|
AckComment = ackComment ?? string.Empty,
|
|
AckOperatorName = ackOperatorName ?? string.Empty,
|
|
AckOperatorDomain = ackOperatorDomain ?? string.Empty,
|
|
AckOperatorNode = ackOperatorNode ?? string.Empty,
|
|
};
|
|
}
|
|
return records;
|
|
}
|
|
|
|
private static int ParseInt(string text)
|
|
{
|
|
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
|
|
? n : 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// wnwrap's XML <c>GUID</c> field is a 32-char hex string with no
|
|
/// dashes (e.g. <c>"BCC4705395424D65BDAABCDEA6A32A73"</c>). Convert
|
|
/// to <see cref="Guid"/>'s canonical 8-4-4-4-12 layout.
|
|
/// </summary>
|
|
/// <param name="hex">The 32-character hex GUID string.</param>
|
|
/// <param name="guid">The parsed GUID, or Empty if parsing fails.</param>
|
|
/// <returns><see langword="true"/> if <paramref name="hex"/> was successfully parsed; otherwise, <see langword="false"/>.</returns>
|
|
public static bool TryParseHexGuid(string? hex, out Guid guid)
|
|
{
|
|
guid = Guid.Empty;
|
|
if (string.IsNullOrWhiteSpace(hex)) return false;
|
|
string trimmed = hex!.Trim();
|
|
if (Guid.TryParse(trimmed, out guid)) return true;
|
|
if (trimmed.Length != 32) return false;
|
|
string canonical =
|
|
trimmed.Substring(0, 8) + "-" +
|
|
trimmed.Substring(8, 4) + "-" +
|
|
trimmed.Substring(12, 4) + "-" +
|
|
trimmed.Substring(16, 4) + "-" +
|
|
trimmed.Substring(20, 12);
|
|
return Guid.TryParse(canonical, out guid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compose the XML payload <c>SetXmlAlarmQuery</c> expects from a
|
|
/// canonical subscription expression
|
|
/// (<c>\\<machine>\Galaxy!<area></c>). The wnwrap
|
|
/// consumer mangles the round-trip but evidently still needs the
|
|
/// call — without it <c>GetXmlCurrentAlarms2</c> fails with
|
|
/// E_FAIL. Best-effort parse: if the subscription doesn't decompose
|
|
/// cleanly, fall back to a permissive ALL-priority/ALL-state form
|
|
/// so the worker doesn't fail to start.
|
|
/// </summary>
|
|
/// <param name="subscription">The subscription expression.</param>
|
|
/// <returns>The XML query payload to pass to <c>SetXmlAlarmQuery</c>.</returns>
|
|
internal static string ComposeXmlAlarmQuery(string subscription)
|
|
{
|
|
string node = Environment.MachineName;
|
|
string provider = "Galaxy";
|
|
string group = string.Empty;
|
|
|
|
if (!string.IsNullOrEmpty(subscription))
|
|
{
|
|
// Strip leading backslashes from "\\<node>\..." form.
|
|
string trimmed = subscription.TrimStart('\\');
|
|
int slash = trimmed.IndexOf('\\');
|
|
if (slash > 0)
|
|
{
|
|
node = trimmed.Substring(0, slash);
|
|
trimmed = trimmed.Substring(slash + 1);
|
|
}
|
|
int bang = trimmed.IndexOf('!');
|
|
if (bang > 0)
|
|
{
|
|
provider = trimmed.Substring(0, bang);
|
|
group = trimmed.Substring(bang + 1);
|
|
}
|
|
else
|
|
{
|
|
provider = trimmed;
|
|
}
|
|
}
|
|
|
|
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
|
sb.Append("<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">");
|
|
sb.Append("<QUERY>");
|
|
sb.Append("<NODE>").Append(node).Append("</NODE>");
|
|
sb.Append("<PROVIDER>").Append(provider).Append("</PROVIDER>");
|
|
if (!string.IsNullOrEmpty(group))
|
|
{
|
|
sb.Append("<GROUP>").Append(group).Append("</GROUP>");
|
|
}
|
|
sb.Append("</QUERY>");
|
|
sb.Append("</QUERIES>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
wwAlarmConsumerClass? clientToDispose;
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
clientToDispose = client;
|
|
client = null;
|
|
}
|
|
ReleaseConsumerCom(clientToDispose);
|
|
}
|
|
|
|
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
|
|
{
|
|
if (consumer is null) return;
|
|
try { consumer.DeregisterConsumer(); } catch { /* swallow */ }
|
|
try { consumer.UninitializeConsumer(); } catch { /* swallow */ }
|
|
if (Marshal.IsComObject(consumer))
|
|
{
|
|
try { Marshal.FinalReleaseComObject(consumer); } catch { /* swallow */ }
|
|
}
|
|
}
|
|
}
|