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
@@ -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