Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs
T
Joseph Doherty 693a78db7d feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.

Two additive proto3 booleans carry the verdict out:

- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)

The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.

Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.

Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.

Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.

Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.

Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
2026-08-17 04:18:34 -04:00

970 lines
45 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml;
using WNWRAPCONSUMERLib;
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>, <c>AcknowledgeBy*</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(() =&gt; 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>
/// </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;
/// <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>
internal const string MaxAlarmsPerFetchEnvironmentVariableName =
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
/// <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 wwAlarmConsumerClass? ackClient;
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(() =&gt; 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="LastSnapshotTruncated"/>
/// / <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(MaxAlarmsPerFetchEnvironmentVariableName);
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) this is the
// only path that lets AlarmAckByName succeed afterwards. The
// v2 Initialize/Register/Subscribe methods on the class
// succeed (return 0) but acks against that consumer state
// return -55.
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. SetXmlAlarmQuery
// also breaks AlarmAckByName on the same consumer (rejects with
// -55), so a separate ack-only consumer is provisioned below
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
//
// 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);
}
// Provision a parallel COM consumer for ack calls. It runs the
// v1 lifecycle (Initialize/Register/Subscribe) only; without
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
// — we never poll this consumer.
ackClient = new wwAlarmConsumerClass();
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
szApplicationName: DefaultApplicationName + ".ack",
szVersion: DefaultVersion);
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
szSubscription: subscription,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
{
throw new InvalidOperationException(
$"Ack consumer setup returned non-zero status: " +
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
}
subscribed = true;
}
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
wwAlarmConsumerClass com = client
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
// VBGUID is wnwrap's GUID interop struct (same memory layout as
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
// unmanaged-blittable round-trip.
VBGUID vb = ToVbGuid(alarmGuid);
return com.AlarmAckByGUID(
AlmGUID: vb,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty,
szDomainName: ackOperatorDomain ?? string.Empty,
szOprFullName: ackOperatorFullName ?? string.Empty);
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
// empirical justification.
wwAlarmConsumerClass com = ackClient
?? throw new InvalidOperationException(
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
// Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2
// 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like
// a stub). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works
// and reaches the alarm-history path correctly. Operator-domain
// and operator-full-name fields are accepted by the proto contract
// for forward-compat but are not propagated to AVEVA today —
// wrapped in the 6-arg call so domain/full-name go to the
// alarm-history operator-name field via the szOprName parameter.
// Suppress unused-warning explicitly:
_ = ackOperatorDomain;
_ = ackOperatorFullName;
return com.AlarmAckByName(
szAlarmName: alarmName ?? string.Empty,
szProviderName: providerName ?? string.Empty,
szGroupName: groupName ?? string.Empty,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty);
}
/// <inheritdoc />
/// <remarks>
/// This is the set the gateway's reconcile pass diffs its cache
/// against, so an alarm missing here is what the gateway reads as a
/// Clear. After a truncated fetch the snapshot deliberately still
/// carries alarms the capped reply could not mention — see
/// <see cref="PollOnce"/>. Erring toward "still active" keeps the feed
/// at-least-once: a stale entry is repaired by the next sub-cap poll,
/// whereas a dropped one is broadcast as a Clear that never happened.
/// </remarks>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
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);
}
}
return active;
}
}
/// <inheritdoc />
/// <remarks>
/// Read without the disposed guard <see cref="SnapshotActiveAlarms"/>
/// carries: this is degraded-status metadata a reply builder stamps
/// alongside a snapshot, and throwing from it would fail a query whose
/// snapshot half succeeded.
/// </remarks>
public bool LastSnapshotTruncated
{
get { lock (syncRoot) { return lastSnapshotTruncated; } }
}
/// <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 is a
// candidate exact signal, but only if it reports the total rather than
// the records in the reply — untested on a live rig, 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. Splitting them would let a concurrent
/// <see cref="SnapshotActiveAlarms"/> / <see cref="LastSnapshotTruncated"/>
/// pair read 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 make
/// the test exact if it reported the total active count rather than the
/// records in this reply; a live probe could not discriminate the two
/// (see <c>docs/AlarmProbeFindings.md</c>), so the count is deliberately
/// not trusted here. 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 only
/// (<c>docs/AlarmClientDiscovery.md</c>); the acknowledge leg and
/// re-raise-after-clear are assumed, not observed, because the dev
/// rig's alarm attributes reject unauthenticated writes — 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;
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;
}
}
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,
};
}
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>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</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();
}
private static VBGUID ToVbGuid(Guid g)
{
byte[] bytes = g.ToByteArray();
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
VBGUID vb = new VBGUID
{
Data1 = BitConverter.ToInt32(bytes, 0),
Data2 = BitConverter.ToInt16(bytes, 4),
Data3 = BitConverter.ToInt16(bytes, 6),
Data4 = new byte[8],
};
Array.Copy(bytes, 8, vb.Data4, 0, 8);
return vb;
}
/// <inheritdoc />
public void Dispose()
{
wwAlarmConsumerClass? clientToDispose;
wwAlarmConsumerClass? ackClientToDispose;
lock (syncRoot)
{
if (disposed) return;
disposed = true;
clientToDispose = client;
client = null;
ackClientToDispose = ackClient;
ackClient = null;
}
ReleaseConsumerCom(clientToDispose);
ReleaseConsumerCom(ackClientToDispose);
}
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 */ }
}
}
}