fix(alarms): truncation-safe transitions; perf: single-pass alarm parse; configurable poll cadence

This commit is contained in:
Joseph Doherty
2026-08-15 17:04:06 -04:00
parent 94fdc18c3c
commit f3e1de5f37
10 changed files with 910 additions and 33 deletions
+50
View File
@@ -135,6 +135,56 @@ alarm state is gateway-wide, not session-scoped — every client wants the same
current set plus updates, and forcing each to own a worker would multiply AVEVA
polling load for no benefit.
### Alarms — a capped snapshot fetch never implies a clear
Decision (2026-08-15): when the worker's `GetXmlCurrentAlarms2` fetch comes back
holding exactly `MxGateway:Alarms:MaxAlarmsPerFetch` records, the worker treats
the snapshot as **truncated** and merges it into the retained snapshot instead
of replacing it. Alarms the capped reply did carry update normally; alarms it
had no room to mention are retained untouched.
The COM API caps its reply at `maxAlmCnt` and exposes no "more available" flag,
so a reply sitting exactly on the cap is indistinguishable from a galaxy that
happens to hold exactly that many active alarms. Both are treated as truncated,
because the two error directions are not symmetric.
Nothing in the worker emits a Clear transition. The clear is an **inference**:
`WnWrapAlarmConsumer.ComputeTransitions` produces no transition for an alarm
that disappears from the snapshot, and `GatewayAlarmMonitor.ApplyReconcile`
later diffs its cache against `SnapshotActiveAlarms()` and broadcasts a Clear
for every cached alarm the worker no longer reports. Before this decision, a
capped fetch shrank that snapshot, so every alarm past the cap was broadcast as
cleared while still standing — a silent, galaxy-wide false clear on exactly the
alarm floods where the cap is reached.
Consequences, and how this sits with the existing failover/reconcile design:
- **The suppression is an eviction guard, not a transition filter.** It lives in
the snapshot update inside `PollOnce`, not in `ComputeTransitions`, which was
never going to emit anything for a disappearance. The reconcile/dedup
machinery (`_clearedByReconcile` tombstones, the NEXT-03 duplicate-Clear
suppression) is untouched: it still sees the same shape of snapshot, only
with the truncated poll's unmentionable alarms still present.
- **It preserves at-least-once, idempotent application.** The failure mode
becomes bounded staleness — a genuinely cleared alarm can linger until the
first sub-cap fetch evicts it, and the reconcile then broadcasts its Clear
late. A late Clear is repaired by the next complete poll; a Clear that never
happened is broadcast to every `StreamAlarms` subscriber and cannot be taken
back. Consumers already apply transitions as "set this alarm to this state",
so a repeated or delayed Clear is absorbed.
- **It does not synthesize anything.** Suppressing an inference is the opposite
of inventing an event; no transition is fabricated on a truncated poll.
- **Failover is unaffected.** `FailoverAlarmConsumer` selects which
`IMxAccessAlarmConsumer` is live; the guard is internal to the wnwrap
consumer's own snapshot bookkeeping and changes neither the failure counting
that triggers failover nor the subtag standby's snapshot, which is built from
a bounded watch-list and has no per-fetch cap to hit.
- **Operators get told.** A truncated poll logs a rate-limited (once per
minute) `AlarmSnapshotTruncated` warning carrying the cap, the record counts,
and the running truncated-fetch total — identifiers and counts only, never
tag names, values, limits, or comments. A galaxy that truncates persistently
is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.
## Session-Resilience Epic Scope
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
+2
View File
@@ -417,6 +417,8 @@ behavior.
| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\<machine>\Galaxy!<area>` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\<MachineName>\Galaxy!<DefaultArea>` if `DefaultArea` is set. |
| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. |
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. |
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be `>= 100`: every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or unusable value leaves the worker on the 500 ms default rather than failing the session. |
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be `>= 64`. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning (identifiers and counts only). Raise this on galaxies whose steady-state active-alarm count approaches the cap — a galaxy permanently above it holds stale entries in the snapshot until a sub-cap poll. Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or unusable value leaves the worker on the 1024 default. |
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor.
@@ -46,6 +46,29 @@ public sealed class AlarmsOptions
/// </summary>
public int ReconcileIntervalSeconds { get; init; } = 30;
/// <summary>
/// Cadence at which the worker's STA polls the AVEVA alarm consumer
/// (<c>GetXmlCurrentAlarms2</c>) for the current active-alarm snapshot.
/// Default 500 ms; must be at least 100 ms. Every poll is a COM call
/// plus an XML parse on the STA that also serves reads and writes, so
/// driving it below 100 ms starves the command path. Conveyed to the
/// worker through the <c>MXGATEWAY_ALARM_POLL_INTERVAL_MS</c>
/// environment variable.
/// </summary>
public int PollIntervalMilliseconds { get; init; } = 500;
/// <summary>
/// Cap the worker passes to <c>GetXmlCurrentAlarms2</c>'s
/// <c>maxAlmCnt</c> argument. Default 1024; must be at least 64. A
/// fetch that comes back holding exactly this many records is treated
/// as truncated: the worker keeps the alarms the capped fetch could
/// not mention in its snapshot rather than letting their absence read
/// as a clear. Raise it on galaxies whose steady-state active-alarm
/// count approaches the cap. Conveyed to the worker through the
/// <c>MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH</c> environment variable.
/// </summary>
public int MaxAlarmsPerFetch { get; init; } = 1024;
/// <summary>
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag
@@ -414,8 +414,26 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
private const int MinimumAlarmPollIntervalMilliseconds = 100;
private const int MinimumMaxAlarmsPerFetch = 64;
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
{
// Validated regardless of Enabled: both values are stamped onto every
// worker launch environment, so a bad value is a misconfiguration even
// before the central monitor is switched on.
if (options.PollIntervalMilliseconds < MinimumAlarmPollIntervalMilliseconds)
{
builder.Add(
$"MxGateway:Alarms:PollIntervalMilliseconds must be greater than or equal to {MinimumAlarmPollIntervalMilliseconds}.");
}
if (options.MaxAlarmsPerFetch < MinimumMaxAlarmsPerFetch)
{
builder.Add(
$"MxGateway:Alarms:MaxAlarmsPerFetch must be greater than or equal to {MinimumMaxAlarmsPerFetch}.");
}
if (!options.Enabled)
{
return;
@@ -29,11 +29,27 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
/// <summary>
/// Conveys MxGateway:Alarms:PollIntervalMilliseconds to the worker: the
/// cadence at which the worker's STA polls the AVEVA alarm consumer.
/// </summary>
public const string WorkerAlarmPollIntervalEnvironmentVariableName =
"MXGATEWAY_ALARM_POLL_INTERVAL_MS";
/// <summary>
/// Conveys MxGateway:Alarms:MaxAlarmsPerFetch to the worker: the cap
/// passed to GetXmlCurrentAlarms2, which is also the record count at
/// which the worker treats a fetch as truncated.
/// </summary>
public const string WorkerMaxAlarmsPerFetchEnvironmentVariableName =
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
private readonly IWorkerProcessFactory _processFactory;
private readonly IWorkerStartupProbe _startupProbe;
private readonly GatewayMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly WorkerOptions _workerOptions;
private readonly AlarmsOptions _alarmsOptions;
private readonly ILogger<WorkerProcessLauncher> _logger;
/// <summary>
@@ -59,6 +75,7 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
ArgumentNullException.ThrowIfNull(metrics);
_workerOptions = gatewayOptions.Value.Worker;
_alarmsOptions = gatewayOptions.Value.Alarms;
_processFactory = processFactory;
_startupProbe = startupProbe;
_metrics = metrics;
@@ -185,6 +202,10 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerAlarmPollIntervalEnvironmentVariableName] =
_alarmsOptions.PollIntervalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
startInfo.Environment[WorkerMaxAlarmsPerFetchEnvironmentVariableName] =
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
@@ -163,6 +163,103 @@ public sealed class GatewayOptionsValidatorTests
Tls = source.Tls,
};
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
[Fact]
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
{
AlarmsOptions alarms = new();
Assert.Equal(500, alarms.PollIntervalMilliseconds);
Assert.Equal(1024, alarms.MaxAlarmsPerFetch);
ValidateOptionsResult result = new GatewayOptionsValidator()
.Validate(null, CloneWithAlarms(ValidOptions(), alarms));
Assert.True(result.Succeeded);
}
/// <summary>
/// A poll cadence below the 100 ms floor must fail validation. Both
/// values are stamped onto every worker launch environment, so they
/// are validated whether or not the central alarm monitor is enabled.
/// </summary>
/// <param name="pollIntervalMilliseconds">Cadence under test.</param>
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
[Theory]
[InlineData(99, false)]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(99, true)]
public void Validate_Fails_WhenAlarmPollIntervalBelowFloor(
int pollIntervalMilliseconds,
bool alarmsEnabled)
{
GatewayOptions options = CloneWithAlarms(
ValidOptions(),
new AlarmsOptions
{
Enabled = alarmsEnabled,
DefaultArea = "Galaxy",
PollIntervalMilliseconds = pollIntervalMilliseconds,
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Alarms:PollIntervalMilliseconds", StringComparison.Ordinal));
}
/// <summary>
/// A per-fetch cap below the 64-record floor must fail validation. The
/// cap doubles as the truncation-detection threshold in the worker, so
/// a tiny cap would make almost every fetch read as truncated.
/// </summary>
/// <param name="maxAlarmsPerFetch">Cap under test.</param>
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
[Theory]
[InlineData(63, false)]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(63, true)]
public void Validate_Fails_WhenMaxAlarmsPerFetchBelowFloor(
int maxAlarmsPerFetch,
bool alarmsEnabled)
{
GatewayOptions options = CloneWithAlarms(
ValidOptions(),
new AlarmsOptions
{
Enabled = alarmsEnabled,
DefaultArea = "Galaxy",
MaxAlarmsPerFetch = maxAlarmsPerFetch,
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Alarms:MaxAlarmsPerFetch", StringComparison.Ordinal));
}
/// <summary>Verifies the floor values themselves are accepted.</summary>
[Fact]
public void Validate_Succeeds_AtAlarmPollCadenceAndFetchCapFloors()
{
GatewayOptions options = CloneWithAlarms(
ValidOptions(),
new AlarmsOptions
{
Enabled = true,
DefaultArea = "Galaxy",
PollIntervalMilliseconds = 100,
MaxAlarmsPerFetch = 64,
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
[Fact]
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
@@ -47,6 +47,17 @@ public sealed class WorkerProcessLauncherTests
"1500",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]);
// MxGateway:Alarms defaults reach the worker's alarm poll loop and its
// GetXmlCurrentAlarms2 cap (which is also its truncation threshold)
// through the launch environment, not the command line.
Assert.Equal(
"500",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerAlarmPollIntervalEnvironmentVariableName]);
Assert.Equal(
"1024",
processFactory.LastStartInfo.Environment[
WorkerProcessLauncher.WorkerMaxAlarmsPerFetchEnvironmentVariableName]);
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
Assert.False(pipeReservation.DisposeCalled);
@@ -317,6 +317,346 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
}
// -------------------------------------------------------------------------
// Truncation cliff (Task 23). GetXmlCurrentAlarms2 caps its reply at
// maxAlmCnt with no "more available" flag. Before the guard, a capped fetch
// shrank the retained snapshot, and every alarm past the cap vanished from
// SnapshotActiveAlarms — which the gateway's reconcile pass reads as a
// Clear and broadcasts to every StreamAlarms subscriber.
// -------------------------------------------------------------------------
/// <summary>
/// A fetch that comes back holding exactly the cap must be treated as
/// truncated; anything below the cap must not.
/// </summary>
/// <param name="fetchedRecordCount">Records the reply carried.</param>
/// <param name="maxAlarmsPerFetch">The cap passed to the fetch.</param>
/// <param name="expected">Whether the reply must read as truncated.</param>
[Theory]
[InlineData(1024, 1024, true)]
[InlineData(1023, 1024, false)]
[InlineData(0, 1024, false)]
[InlineData(64, 64, true)]
public void IsTruncatedFetch_AtOrAboveCap_IsTruncated(
int fetchedRecordCount,
int maxAlarmsPerFetch,
bool expected)
{
Assert.Equal(
expected,
WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch));
}
/// <summary>
/// THE truncation-correctness test. A 1024-record fetch against a
/// 1024 cap that does not mention a known-active alarm must NOT evict
/// that alarm from the retained snapshot: the snapshot is what
/// <see cref="WnWrapAlarmConsumer.SnapshotActiveAlarms"/> returns and
/// what the gateway's reconcile diffs its cache against, so an
/// eviction here is a Clear broadcast for an alarm that never cleared.
/// </summary>
[Fact]
public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch()
{
const int Cap = 1024;
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
string xml = BuildAlarmXml(Cap);
Dictionary<Guid, MxAlarmSnapshotRecord> next =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(Cap, fetchedRecordCount);
Assert.Equal(Cap, next.Count);
Assert.False(next.ContainsKey(missingGuid));
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
{
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
};
// The diff itself never emits a Clear for a disappearance — the Clear
// is the eviction, one level up. Pin both halves.
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
Assert.DoesNotContain(transitions, t => t.Record.AlarmGuid == missingGuid);
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
// NO Clear: the alarm the capped fetch had no room to mention survives,
// so the gateway's reconcile still sees it active.
Assert.True(snapshot.ContainsKey(missingGuid));
Assert.Equal(MxAlarmStateKind.UnackAlm, snapshot[missingGuid].State);
Assert.Equal(Cap + 1, snapshot.Count);
}
/// <summary>
/// The control case: the same missing alarm against a sub-cap fetch
/// must still be evicted, because a complete fetch IS authoritative
/// about absence. Without this, the truncation guard would have
/// silently disabled clears altogether.
/// </summary>
[Fact]
public void ApplySnapshotUpdate_WhenFetchBelowCap_EvictsAlarmMissingFromFetch()
{
const int Cap = 1024;
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
string xml = BuildAlarmXml(Cap - 1);
Dictionary<Guid, MxAlarmSnapshotRecord> next =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(Cap - 1, fetchedRecordCount);
Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
{
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
};
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: false);
// Clear as today: the alarm drops out of the snapshot, and the
// gateway's reconcile turns that absence into a Clear.
Assert.False(snapshot.ContainsKey(missingGuid));
Assert.Equal(Cap - 1, snapshot.Count);
}
/// <summary>
/// A truncated fetch must still apply the alarms it DID carry — the
/// guard suppresses eviction, not the update. An alarm whose state
/// changed inside a capped reply still transitions.
/// </summary>
[Fact]
public void ApplySnapshotUpdate_WhenFetchTruncated_StillAppliesPresentAlarms()
{
Guid presentGuid = new Guid("22222222-2222-2222-2222-222222222222");
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
{
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm),
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
MxAlarmTransitionEvent single = Assert.Single(transitions);
Assert.Equal(presentGuid, single.Record.AlarmGuid);
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
Assert.Equal(MxAlarmStateKind.AckAlm, snapshot[presentGuid].State);
Assert.True(snapshot.ContainsKey(missingGuid));
}
/// <summary>
/// Truncation is detected from the raw ALARM-element count, not the
/// parsed dictionary size. A record dropped for a malformed GUID still
/// consumed a slot in the capped reply, so counting only survivors
/// would let a truncated fetch pass as complete and re-open the cliff.
/// </summary>
[Fact]
public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid()
{
const int Cap = 64;
string xml = BuildAlarmXml(Cap).Replace(
"<GUID>00000000000000000000000000000001</GUID>",
"<GUID>not-a-guid</GUID>");
Dictionary<Guid, MxAlarmSnapshotRecord> records =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(Cap, fetchedRecordCount);
Assert.Equal(Cap - 1, records.Count);
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
}
/// <summary>
/// The per-fetch cap comes from the launcher-set environment variable;
/// a missing, unparseable, or below-floor value must fall back to the
/// 1024 default rather than throw. A bad environment value must never
/// stop the alarm consumer from starting.
/// </summary>
/// <param name="environmentValue">Raw environment value under test.</param>
/// <param name="expected">Expected resolved cap.</param>
[Theory]
[InlineData(null, 1024)]
[InlineData("", 1024)]
[InlineData("not-a-number", 1024)]
[InlineData("0", 1024)]
[InlineData("-5", 1024)]
[InlineData("63", 1024)]
[InlineData("64", 64)]
[InlineData("4096", 4096)]
public void ResolveMaxAlarmsPerFetch_WithEnvironmentValue_FallsBackToDefaultWhenUnusable(
string? environmentValue,
int expected)
{
string? original = Environment.GetEnvironmentVariable(
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName);
try
{
Environment.SetEnvironmentVariable(
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
environmentValue);
Assert.Equal(expected, WnWrapAlarmConsumer.ResolveMaxAlarmsPerFetch());
}
finally
{
Environment.SetEnvironmentVariable(
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
original);
}
}
/// <summary>
/// Builds a well-formed ALARM_RECORDS payload with
/// <paramref name="count"/> distinct alarms. GUIDs are the dashless
/// 32-char hex form wnwrap actually emits, numbered from 1 so a test
/// can name one (e.g. ...0001) to corrupt.
/// </summary>
/// <param name="count">Number of ALARM elements to emit.</param>
/// <returns>The XML payload.</returns>
private static string BuildAlarmXml(int count)
{
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.Append("<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"")
.Append(count)
.Append("\">");
for (int index = 1; index <= count; index++)
{
sb.Append("<ALARM><GUID>")
.Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture))
.Append("</GUID>")
.Append("<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>")
.Append("<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>")
.Append("<PROVIDER_NODE>TEST-NODE</PROVIDER_NODE>")
.Append("<PROVIDER_NAME>Galaxy</PROVIDER_NAME>")
.Append("<GROUP>TestArea</GROUP>")
.Append("<TAGNAME>TestMachine_")
.Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture))
.Append(".TestAlarm</TAGNAME>")
.Append("<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>")
.Append("<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>")
.Append("<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>")
.Append("<ALARM_COMMENT>Test alarm</ALARM_COMMENT></ALARM>");
}
sb.Append("</ALARM_RECORDS>");
return sb.ToString();
}
// -------------------------------------------------------------------------
// Single-pass parse parity (Task 23). The per-field SelectSingleNode calls
// were replaced by one walk over ChildNodes; these pin the semantics that
// walk has to reproduce.
// -------------------------------------------------------------------------
/// <summary>
/// An absent child element and a present-but-empty one must both yield
/// <see cref="string.Empty"/>, exactly as
/// <c>SelectSingleNode(name)?.InnerText ?? string.Empty</c> did.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<TAGNAME></TAGNAME>" +
"<STATE>UNACK_ALM</STATE></ALARM>" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
Assert.Equal(string.Empty, record.TagName); // present but empty
Assert.Equal(string.Empty, record.ProviderNode); // absent
Assert.Equal(string.Empty, record.Value); // absent
Assert.Equal(string.Empty, record.AlarmComment); // absent
Assert.Equal(0, record.Priority); // absent → ParseInt("") → 0
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
}
/// <summary>
/// Duplicate child elements must resolve to the FIRST occurrence —
/// <c>SelectSingleNode</c> returned the first match, so a last-wins
/// walk would silently change which value a malformed payload yields.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<TAGNAME>First</TAGNAME><TAGNAME>Second</TAGNAME>" +
"<PRIORITY>100</PRIORITY><PRIORITY>900</PRIORITY>" +
"<STATE>UNACK_ALM</STATE></ALARM>" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
Assert.Equal("First", record.TagName);
Assert.Equal(100, record.Priority);
}
/// <summary>
/// Field names are matched ordinally and case-sensitively, as the
/// XPath node test was. A lowercase element must not populate the
/// field its uppercase counterpart owns.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<tagname>ShouldBeIgnored</tagname>" +
"<STATE>UNACK_ALM</STATE></ALARM>" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
Assert.Equal(string.Empty, record.TagName);
}
/// <summary>
/// Non-element children (whitespace text, comments, CDATA) must be
/// skipped rather than matched by name, and a nested element must
/// contribute its InnerText exactly as <c>InnerText</c> always did.
/// </summary>
[Fact]
public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren()
{
string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">\n" +
" <ALARM>\n" +
" <!-- a comment -->\n" +
" <GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>\n" +
" <TAGNAME>TestMachine.TestAlarm</TAGNAME>\n" +
" <STATE>UNACK_ALM</STATE>\n" +
" </ALARM>\n" +
"</ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records =
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
Assert.Equal(1, fetchedRecordCount);
MxAlarmSnapshotRecord record = Assert.Single(records).Value;
Assert.Equal("TestMachine.TestAlarm", record.TagName);
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
}
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
{
return new MxAlarmSnapshotRecord
@@ -19,7 +19,26 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
internal const string WriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
/// <summary>
/// Environment variable the gateway's WorkerProcessLauncher sets from
/// MxGateway:Alarms:PollIntervalMilliseconds. A missing or invalid
/// value falls back to <see cref="DefaultAlarmPollInterval"/>.
/// </summary>
internal const string AlarmPollIntervalEnvironmentVariableName =
"MXGATEWAY_ALARM_POLL_INTERVAL_MS";
/// <summary>
/// Floor on the resolved alarm poll cadence. Mirrors the gateway-side
/// MxGateway:Alarms:PollIntervalMilliseconds minimum so a value that
/// slipped past validation (or an environment set by hand) still can
/// not starve the STA's command path.
/// </summary>
internal static readonly TimeSpan MinimumAlarmPollInterval = TimeSpan.FromMilliseconds(100);
/// <summary>Default alarm poll cadence when the environment says nothing usable.</summary>
internal static readonly TimeSpan DefaultAlarmPollInterval = TimeSpan.FromMilliseconds(500);
private readonly TimeSpan alarmPollInterval = ResolveAlarmPollInterval();
private readonly IMxAccessComObjectFactory factory;
private readonly IMxAccessEventSink eventSink;
@@ -191,6 +210,31 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
}
/// <summary>
/// Resolves the alarm poll cadence from the launcher-provided
/// environment variable. A missing, unparseable, or out-of-range value
/// falls back to <see cref="DefaultAlarmPollInterval"/> rather than
/// faulting the worker: an alarm poll that runs at the default cadence
/// is always safe, and a session that refuses to start over a
/// mistyped environment variable is not.
/// </summary>
/// <returns>The cadence the alarm poll loop waits between polls.</returns>
internal static TimeSpan ResolveAlarmPollInterval()
{
string? value = Environment.GetEnvironmentVariable(AlarmPollIntervalEnvironmentVariableName);
if (!int.TryParse(
value,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out int milliseconds))
{
return DefaultAlarmPollInterval;
}
TimeSpan resolved = TimeSpan.FromMilliseconds(milliseconds);
return resolved < MinimumAlarmPollInterval ? DefaultAlarmPollInterval : resolved;
}
/// <summary>
/// Starts the MXAccess COM session asynchronously.
/// </summary>
@@ -274,7 +318,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
{
try
{
await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false);
await Task.Delay(alarmPollInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml;
@@ -49,11 +50,39 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
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>
/// Environment variable the gateway's <c>WorkerProcessLauncher</c>
/// sets from <c>MxGateway:Alarms:MaxAlarmsPerFetch</c>. A missing,
/// unparseable, or below-floor 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 wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient;
private bool subscribed;
@@ -67,7 +96,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// every COM call stays on the STA that owns the apartment.
/// </summary>
public WnWrapAlarmConsumer()
: this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch)
: this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch())
{
}
@@ -86,6 +115,27 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
: DefaultMaxAlarmsPerFetch;
}
/// <summary>
/// Resolves the per-fetch cap from the launcher-provided environment
/// variable. A missing, unparseable, or below-floor 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.
/// </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
: DefaultMaxAlarmsPerFetch;
}
/// <summary>
/// Fires once per detected alarm-state transition (raise, acknowledge,
/// clear, or new-alarm-already-acked-on-arrival), dispatched from
@@ -282,6 +332,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
/// <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));
@@ -300,6 +359,16 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
}
/// <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()
{
@@ -316,17 +385,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
string xml = xmlObj?.ToString() ?? string.Empty;
if (xml.Length == 0) return;
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
// and gives no "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.
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
IReadOnlyList<MxAlarmTransitionEvent> transitions;
int retainedCount;
lock (syncRoot)
{
transitions = ComputeTransitions(latestSnapshot, next);
latestSnapshot.Clear();
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
latestSnapshot[kv.Key] = kv.Value;
}
ApplySnapshotUpdate(latestSnapshot, next, truncated);
retainedCount = latestSnapshot.Count;
}
if (truncated)
{
WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount);
}
if (transitions.Count == 0) return;
@@ -338,6 +418,111 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
}
/// <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 "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. 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 (elapsed - lastTruncationWarningMilliseconds < TruncationWarningIntervalMilliseconds)
{
return;
}
lastTruncationWarningMilliseconds = elapsed;
}
// Identifiers and counts only — no tag names, values, limits, or
// comments, per the gateway's "don't log tag values by default" rule.
string message = string.Format(
CultureInfo.InvariantCulture,
"level=Warning event=AlarmSnapshotTruncated maxAlarmsPerFetch={0} fetchedRecords={1} "
+ "parsedRecords={2} retainedSnapshotSize={3} truncatedFetchesSinceStart={4} "
+ "note=absence-implies-clear suppressed for this poll; raise MxGateway:Alarms:MaxAlarmsPerFetch",
maxAlarmsPerFetch,
fetchedRecordCount,
parsedRecordCount,
retainedCount,
total);
Action<string> sink = TruncationWarningSink ?? Console.Error.WriteLine;
sink(message);
}
/// <summary>
/// Pure snapshot-to-transitions diff. Compares the previous polled
/// snapshot to the next snapshot and produces one
@@ -355,6 +540,21 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <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>
/// </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>
@@ -393,9 +593,29 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <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;
@@ -404,45 +624,96 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
if (alarmNodes is null) return records;
alarmRecordCount = alarmNodes.Count;
foreach (XmlNode alarmNode in alarmNodes)
{
string guidHex = TextOf(alarmNode, "GUID");
// 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.
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;
string xmlDate = TextOf(alarmNode, "DATE");
string xmlTime = TextOf(alarmNode, "TIME");
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
xmlDate, xmlTime, gmtOffset, dstAdjust);
xmlDate ?? string.Empty,
xmlTime ?? string.Empty,
ParseInt(gmtOffset ?? string.Empty),
ParseInt(dstAdjust ?? string.Empty));
records[guid] = new MxAlarmSnapshotRecord
{
AlarmGuid = guid,
TransitionTimestampUtc = tsUtc,
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
Group = TextOf(alarmNode, "GROUP"),
TagName = TextOf(alarmNode, "TAGNAME"),
Type = TextOf(alarmNode, "TYPE"),
Value = TextOf(alarmNode, "VALUE"),
Limit = TextOf(alarmNode, "LIMIT"),
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
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 string TextOf(XmlNode parent, string childName)
{
XmlNode? node = parent.SelectSingleNode(childName);
return node?.InnerText ?? string.Empty;
}
private static int ParseInt(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)