refactor(worker): alarm consumers resolve the AckMsg item instead of acking

IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:35:49 -04:00
parent 8199553734
commit 0d4b2aea2d
11 changed files with 238 additions and 452 deletions
@@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests
}
/// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 11;
public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("primary.guid.AckMsg");
/// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11;
public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("primary.name.AckMsg");
/// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests
public void PollOnce() { }
/// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 22;
public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("standby.guid.AckMsg");
/// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22;
public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("standby.name.AckMsg");
/// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests
Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount);
}
/// <summary>Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active.</summary>
/// <summary>Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active.</summary>
[Fact]
public void Acknowledge_DelegatesToActiveChild()
public void ResolveAckTarget_DelegatesToActiveChild()
{
FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false };
StubStandby standby = new StubStandby();
@@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests
sut.Subscribe(@"\\HOST\Galaxy!Area");
// Active = Primary → primary's sentinel value (11).
Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full"));
Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full"));
// Active = Primary → primary's sentinel targets.
Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
// Force a failover by failing the primary past threshold.
primary.ThrowOnPoll = true;
sut.PollOnce(); // threshold=1 → switch to Standby
Assert.Equal(AlarmProviderMode.Subtag, sut.Mode);
// Active = Standby → standby's sentinel value (22).
Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full"));
Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full"));
// Active = Standby → standby's sentinel targets.
Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
}
/// <summary>
@@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when
/// it was not previously advised and writes with user id 0.
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises and
/// removes every advised handle and unregisters the server exactly once.
/// </summary>
[Fact]
public void Write_AddsItemWhenUnknownAndWrites()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Write("Tank1.Alarm.AckComment", "acknowledged");
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
Assert.Single(server.Writes);
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
Assert.Equal(FakeServerHandle, write.ServerHandle);
Assert.Equal("acknowledged", write.Value);
Assert.Equal(0, write.UserId);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
/// item handle (no duplicate AddItem) when the address was already
/// advised.
/// </summary>
[Fact]
public void Write_ReusesHandleForAdvisedAddress()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag" });
int adviseAddCount = server.AddedItems.Count;
source.Write("Tank1.Alarm.Subtag", true);
// No second AddItem for the already-advised address.
Assert.Equal(adviseAddCount, server.AddedItems.Count);
Assert.Single(server.Writes);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises only the
/// handles that were actually advised — a write-only item (added by
/// <see cref="LmxSubtagAlarmSource.Write"/> but never advised) is removed
/// but not unadvised — and unregisters the server exactly once.
/// </summary>
[Fact]
public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce()
public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce()
{
var server = new RecordingMxAccessServer();
var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
// A write-only subtag: added by Write, never advised.
source.Write("Tank1.Alarm.AckComment", "acknowledged");
int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag");
int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag");
int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment");
source.Dispose();
// Only the two advised handles are unadvised — never the write-only one.
Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles);
Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles);
// Every added item (advised + write-only) is removed.
Assert.Equal(
new[] { advised1, advised2, writeOnly }.OrderBy(h => h),
new[] { advised1, advised2 }.OrderBy(h => h),
server.RemovedItemHandles.OrderBy(h => h));
Assert.Equal(1, server.UnregisterCount);
Assert.Empty(server.Writes);
}
/// <summary>
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag
/// fallback advises the observable subtags, synthesizes degraded
/// transitions with stable synthetic GUIDs, routes acknowledgments to the
/// transitions with stable synthetic GUIDs, resolves acknowledgments to the
/// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/>
/// stands in for the live MXAccess subtag source so this needs no AVEVA
/// install.
@@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests
return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
}
/// <summary>Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item).</summary>
/// <summary>
/// Verifies Subscribe advises the observable active, acked and priority
/// subtags but not the ack-comment subtag: the ack is written by
/// <see cref="AlarmAckWriter"/>, which adds and advises it on its own server.
/// </summary>
[Fact]
public void Subscribe_AdvisesAllSubtagsIncludingAckComment()
public void Subscribe_AdvisesObservableSubtagsButNotAckComment()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests
Assert.Contains(ActiveSubtag, source.Advised);
Assert.Contains(AckedSubtag, source.Advised);
Assert.Contains(PrioritySubtag, source.Advised);
Assert.Contains(AckCommentSubtag, source.Advised);
Assert.DoesNotContain(AckCommentSubtag, source.Advised);
}
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
@@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
}
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary>
/// <summary>Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag.</summary>
[Fact]
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
public void ResolveAckTargetByName_ReturnsAckCommentSubtag()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea");
Assert.Equal(0, rc);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("operator ack", source.LastWrite!.Value.Value);
Assert.True(target.Found);
Assert.Equal(AckCommentSubtag, target.ItemAddress);
}
/// <summary>Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.</summary>
/// <summary>Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name.</summary>
[Fact]
public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero()
public void ResolveAckTargetByName_UnknownAlarm_IsNotFound()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName(
alarmName: "DoesNotExist.NoSuchAlarm",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
Assert.False(target.Found);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure);
}
/// <summary>Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID.</summary>
[Fact]
public void ResolveAckTarget_EmptyAckCommentSubtag_IsNotAckable()
{
AlarmSubtagTarget noAck = BuildTarget();
noAck.AckCommentSubtag = string.Empty;
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, new[] { noAck });
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea").Failure);
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTarget(SyntheticAlarmGuid.ForReference(Reference)).Failure);
}
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
@@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests
/// <summary>
/// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi),
/// AcknowledgeByName routes each ack to its own ack-comment subtag and never
/// ResolveAckTargetByName resolves each to its own ack-comment subtag and never
/// conflates the shorter name with the longer one.
/// </summary>
[Fact]
public void AcknowledgeByName_PrefixNameDoesNotFalseMatch()
public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch()
{
const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi";
const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi";
@@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests
source, new[] { targetHi, targetHiHi });
consumer.Subscribe(@"\\HOST\Galaxy!Area");
// Ack the shorter name — must write to the shorter target's subtag only.
int rcHi = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.Hi",
providerName: "Galaxy",
groupName: "Area",
ackComment: "ack hi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hi", source.LastWrite.Value.Value);
// Ack the longer name — must write to the longer target's subtag.
int rcHiHi = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "Area",
ackComment: "ack hihi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHiHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hihi", source.LastWrite.Value.Value);
Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress);
Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress);
Assert.Equal(
AlarmAckOutcome.AlarmNotFound,
consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure);
}
/// <summary>
/// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from
/// the alarm's full reference) to the correct target and writes the comment
/// to that target's ack-comment subtag.
/// Verifies ResolveAckTarget resolves the synthetic GUID (computed from
/// the alarm's full reference) to that target's ack-comment subtag.
/// </summary>
[Fact]
public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag()
public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests
Guid syntheticGuid = emitted!.Record.AlarmGuid;
Assert.NotEqual(Guid.Empty, syntheticGuid);
int rc = consumer.AcknowledgeByGuid(
alarmGuid: syntheticGuid,
ackComment: "guid ack",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid);
Assert.Equal(0, rc);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("guid ack", source.LastWrite.Value.Value);
Assert.True(target.Found);
Assert.Equal(AckCommentSubtag, target.ItemAddress);
}
/// <summary>
/// Verifies AcknowledgeByGuid returns non-zero and performs no write when
/// the supplied GUID is not known to the consumer.
/// </summary>
/// <summary>Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer.</summary>
[Fact]
public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero()
public void ResolveAckTarget_UnknownGuid_IsNotFound()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByGuid(
alarmGuid: Guid.NewGuid(),
ackComment: "should not write",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
}
private sealed class FakeSource : ISubtagAlarmSource
@@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
public List<string> Advised { get; } = new List<string>();
/// <summary>Gets the most recent (address, value) pair passed to <see cref="Write"/>.</summary>
public (string Address, object? Value)? LastWrite { get; private set; }
/// <inheritdoc />
public void Advise(IReadOnlyCollection<string> itemAddresses)
{
Advised.AddRange(itemAddresses);
}
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
LastWrite = (itemAddress, value);
}
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
/// <param name="address">The subtag address whose value changed.</param>
/// <param name="value">The new value.</param>
@@ -824,4 +824,58 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal("mgack-authwrite 180714", record.AckComment);
Assert.Equal("Test alarm #1", record.AlarmComment);
}
/// <summary>A Galaxy-provider record resolves to its <c>&lt;tag&gt;.AckMsg</c> item; an unknown GUID is not found.</summary>
[Fact]
public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
});
AlarmAckTarget target = consumer.ResolveAckTarget(guid);
Assert.True(target.Found);
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
}
/// <summary>A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable.</summary>
[Fact]
public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
});
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
}
/// <summary>By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item.</summary>
[Fact]
public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
{
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
}
private static WnWrapAlarmConsumer CreateConsumerWithSnapshot(params MxAlarmSnapshotRecord[] records)
{
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(1024);
Dictionary<Guid, MxAlarmSnapshotRecord> next = new Dictionary<Guid, MxAlarmSnapshotRecord>();
foreach (MxAlarmSnapshotRecord record in records)
{
next[record.AlarmGuid] = record;
}
consumer.FoldFetch(next, truncated: false, out _);
return consumer;
}
}