diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 2068527..6a1d8f3 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -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 diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 9d269e1..4231dfe 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -417,6 +417,8 @@ behavior. | `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\\Galaxy!` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\\Galaxy!` 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. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs index 565eac8..98259f5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs @@ -46,6 +46,29 @@ public sealed class AlarmsOptions /// public int ReconcileIntervalSeconds { get; init; } = 30; + /// + /// Cadence at which the worker's STA polls the AVEVA alarm consumer + /// (GetXmlCurrentAlarms2) 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 MXGATEWAY_ALARM_POLL_INTERVAL_MS + /// environment variable. + /// + public int PollIntervalMilliseconds { get; init; } = 500; + + /// + /// Cap the worker passes to GetXmlCurrentAlarms2's + /// maxAlmCnt 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 + /// MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH environment variable. + /// + public int MaxAlarmsPerFetch { get; init; } = 1024; + /// /// Configuration for the alarm-manager ↔ subtag fallback mechanism: /// operating mode, failure-detection thresholds, discovery, and subtag diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 3f98691..29a3aa6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -414,8 +414,26 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase + /// Conveys MxGateway:Alarms:PollIntervalMilliseconds to the worker: the + /// cadence at which the worker's STA polls the AVEVA alarm consumer. + /// + public const string WorkerAlarmPollIntervalEnvironmentVariableName = + "MXGATEWAY_ALARM_POLL_INTERVAL_MS"; + + /// + /// 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. + /// + 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 _logger; /// @@ -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); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index f5f2120..40a62b9 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -163,6 +163,103 @@ public sealed class GatewayOptionsValidatorTests Tls = source.Tls, }; + /// Verifies the alarm poll cadence and per-fetch cap defaults pass validation. + [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); + } + + /// + /// 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. + /// + /// Cadence under test. + /// Whether the central alarm monitor is on. + [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)); + } + + /// + /// 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. + /// + /// Cap under test. + /// Whether the central alarm monitor is on. + [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)); + } + + /// Verifies the floor values themselves are accepted. + [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); + } + /// Verifies an invalid fallback mode is not validated when alarms are disabled. [Fact] public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs index 52b3756..6acef16 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs @@ -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); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 7a455c2..682365d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -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. + // ------------------------------------------------------------------------- + + /// + /// A fetch that comes back holding exactly the cap must be treated as + /// truncated; anything below the cap must not. + /// + /// Records the reply carried. + /// The cap passed to the fetch. + /// Whether the reply must read as truncated. + [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)); + } + + /// + /// 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 + /// 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. + /// + [Fact] + public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch() + { + const int Cap = 1024; + Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111"); + + string xml = BuildAlarmXml(Cap); + Dictionary 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 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 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); + } + + /// + /// 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. + /// + [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 next = + WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount); + + Assert.Equal(Cap - 1, fetchedRecordCount); + Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); + + Dictionary 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); + } + + /// + /// 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. + /// + [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 snapshot = new() + { + [presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm), + [missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm), + }; + Dictionary next = new() + { + [presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm), + }; + + IReadOnlyList 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)); + } + + /// + /// 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. + /// + [Fact] + public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid() + { + const int Cap = 64; + string xml = BuildAlarmXml(Cap).Replace( + "00000000000000000000000000000001", + "not-a-guid"); + + Dictionary records = + WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount); + + Assert.Equal(Cap, fetchedRecordCount); + Assert.Equal(Cap - 1, records.Count); + Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); + } + + /// + /// 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. + /// + /// Raw environment value under test. + /// Expected resolved cap. + [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); + } + } + + /// + /// Builds a well-formed ALARM_RECORDS payload with + /// 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. + /// + /// Number of ALARM elements to emit. + /// The XML payload. + private static string BuildAlarmXml(int count) + { + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + sb.Append(""); + for (int index = 1; index <= count; index++) + { + sb.Append("") + .Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture)) + .Append("") + .Append("2026/5/1") + .Append("2400") + .Append("TEST-NODE") + .Append("Galaxy") + .Append("TestArea") + .Append("TestMachine_") + .Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Append(".TestAlarm") + .Append("DSCtruetrue") + .Append("500UNACK_ALM") + .Append("") + .Append("Test alarm"); + } + sb.Append(""); + 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. + // ------------------------------------------------------------------------- + + /// + /// An absent child element and a present-but-empty one must both yield + /// , exactly as + /// SelectSingleNode(name)?.InnerText ?? string.Empty did. + /// + [Fact] + public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings() + { + string xml = + "" + + "BCC4705395424D65BDAABCDEA6A32A73" + + "" + + "UNACK_ALM" + + ""; + + Dictionary 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); + } + + /// + /// Duplicate child elements must resolve to the FIRST occurrence — + /// SelectSingleNode returned the first match, so a last-wins + /// walk would silently change which value a malformed payload yields. + /// + [Fact] + public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence() + { + string xml = + "" + + "BCC4705395424D65BDAABCDEA6A32A73" + + "FirstSecond" + + "100900" + + "UNACK_ALM" + + ""; + + Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml); + MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]; + + Assert.Equal("First", record.TagName); + Assert.Equal(100, record.Priority); + } + + /// + /// 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. + /// + [Fact] + public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField() + { + string xml = + "" + + "BCC4705395424D65BDAABCDEA6A32A73" + + "ShouldBeIgnored" + + "UNACK_ALM" + + ""; + + Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml); + MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]; + + Assert.Equal(string.Empty, record.TagName); + } + + /// + /// 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 InnerText always did. + /// + [Fact] + public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren() + { + string xml = + "\n" + + " \n" + + " \n" + + " BCC4705395424D65BDAABCDEA6A32A73\n" + + " TestMachine.TestAlarm\n" + + " UNACK_ALM\n" + + " \n" + + ""; + + Dictionary 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 diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs index 03ddf16..20741b6 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -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); + /// + /// Environment variable the gateway's WorkerProcessLauncher sets from + /// MxGateway:Alarms:PollIntervalMilliseconds. A missing or invalid + /// value falls back to . + /// + internal const string AlarmPollIntervalEnvironmentVariableName = + "MXGATEWAY_ALARM_POLL_INTERVAL_MS"; + + /// + /// 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. + /// + internal static readonly TimeSpan MinimumAlarmPollInterval = TimeSpan.FromMilliseconds(100); + + /// Default alarm poll cadence when the environment says nothing usable. + 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; } + /// + /// Resolves the alarm poll cadence from the launcher-provided + /// environment variable. A missing, unparseable, or out-of-range value + /// falls back to 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. + /// + /// The cadence the alarm poll loop waits between polls. + 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; + } + /// /// Starts the MXAccess COM session asynchronously. /// @@ -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) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 8dd78b9..18085e1 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -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; + /// + /// Floor on the resolved per-fetch cap. Mirrors the gateway-side + /// MxGateway:Alarms:MaxAlarmsPerFetch minimum so a value that + /// slipped past startup validation still leaves a usable snapshot + /// window. + /// + internal const int MinimumMaxAlarmsPerFetch = 64; + + /// + /// Environment variable the gateway's WorkerProcessLauncher + /// sets from MxGateway:Alarms:MaxAlarmsPerFetch. A missing, + /// unparseable, or below-floor value falls back to + /// — a bad environment value + /// must never keep the alarm consumer from starting. + /// + internal const string MaxAlarmsPerFetchEnvironmentVariableName = + "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH"; + + /// + /// 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. + /// + private const long TruncationWarningIntervalMilliseconds = 60_000; + private readonly object syncRoot = new object(); private readonly Dictionary latestSnapshot = new Dictionary(); 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. /// public WnWrapAlarmConsumer() - : this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch) + : this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch()) { } @@ -86,6 +115,27 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer : DefaultMaxAlarmsPerFetch; } + /// + /// Resolves the per-fetch cap from the launcher-provided environment + /// variable. A missing, unparseable, or below-floor value falls back + /// to 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 cap passed to GetXmlCurrentAlarms2. + internal static int ResolveMaxAlarmsPerFetch() + { + string? value = Environment.GetEnvironmentVariable(MaxAlarmsPerFetchEnvironmentVariableName); + return int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int cap) + && cap >= MinimumMaxAlarmsPerFetch + ? cap + : DefaultMaxAlarmsPerFetch; + } + /// /// 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 } /// + /// + /// 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 + /// . 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. + /// public IReadOnlyList SnapshotActiveAlarms() { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); @@ -300,6 +359,16 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } + /// + /// Sink for the rate-limited truncated-fetch warning. Defaults to + /// , the stream + /// WorkerConsoleLogger 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. + /// + internal Action? TruncationWarningSink { get; set; } + /// public void PollOnce() { @@ -316,17 +385,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer string xml = xmlObj?.ToString() ?? string.Empty; if (xml.Length == 0) return; - Dictionary next = ParseSnapshotXml(xml); + Dictionary 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 transitions; + int retainedCount; lock (syncRoot) { transitions = ComputeTransitions(latestSnapshot, next); - latestSnapshot.Clear(); - foreach (KeyValuePair 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 } } + /// + /// Decides whether a fetch that came back holding + /// records hit the cap. + /// GetXmlCurrentAlarms2 caps its reply at maxAlmCnt 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 + /// internal static so the rule is unit-testable without the + /// wnwrapConsumer COM object. + /// + /// ALARM records the reply carried. + /// The cap that was passed to the fetch. + /// when the reply must be treated as truncated. + internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch) + { + return fetchedRecordCount >= maxAlarmsPerFetch; + } + + /// + /// Folds a freshly fetched snapshot into the retained one. + /// + /// + /// + /// A complete fetch is authoritative about absence: + /// replacing the snapshot wholesale is exactly what lets a cleared + /// alarm drop out of , which is + /// the evidence the gateway's reconcile pass turns into a Clear. + /// + /// + /// A truncated fetch is authoritative about presence + /// 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. + /// + /// + /// The retained snapshot, updated in place. + /// The snapshot just parsed from the fetch. + /// Whether the fetch hit the per-fetch cap. + internal static void ApplySnapshotUpdate( + Dictionary snapshot, + Dictionary 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 kv in next) + { + snapshot[kv.Key] = kv.Value; + } + } + + /// + /// Emits the truncated-fetch warning at most once per + /// , counting every + /// truncated poll in between so the operator can tell a one-off burst + /// from a galaxy permanently parked above the cap. + /// + /// ALARM records the capped reply carried. + /// Records that survived GUID parsing. + /// Snapshot size after the merge. + 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 sink = TruncationWarningSink ?? Console.Error.WriteLine; + sink(message); + } + /// /// 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 /// A GUID present in both with a different produces a transition carrying the prior state. /// A GUID present in but absent from produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them. /// + /// + /// 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 , which reads the + /// snapshot maintains from these same + /// dictionaries. That is why the truncation guard lives in + /// '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 straight through + /// stays correct for the alarms it does carry — first sightings + /// and state changes are computed from presence alone. + /// /// /// The snapshot from the previous poll (or empty on first call). /// The snapshot just parsed from GetXmlCurrentAlarms2. @@ -393,9 +593,29 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer /// The XML snapshot payload. /// The parsed alarm snapshot records, keyed by alarm GUID. public static Dictionary ParseSnapshotXml(string xml) + { + return ParseSnapshotXml(xml, out _); + } + + /// + /// plus the raw + /// ALARM-element count, which 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. + /// + /// The XML snapshot payload. + /// Number of ALARM elements in the payload. + /// The parsed alarm snapshot records, keyed by alarm GUID. + internal static Dictionary ParseSnapshotXml( + string xml, + out int alarmRecordCount) { Dictionary records = new Dictionary(); + 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)