diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
index 8ce372e..77aa58c 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs
@@ -58,8 +58,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
// Whether the worker's most recent reconcile fetch was capped, guarded by _sync.
- // Written only by ApplyReconcile, so it always describes the same pass that
- // produced the current _alarms generation.
+ // Written only by ApplyReconcile (and cleared with the cache), so it describes the last full
+ // reconcile — not necessarily the current _alarms contents, which live transitions keep moving
+ // via ApplyTransition between passes. Read it as "as of the last reconcile, the worker's fetch
+ // was capped", which is the right granularity for a completeness caveat.
private bool _snapshotTruncated;
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs
index a2df0ef..c1556a7 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs
@@ -262,6 +262,72 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("Galaxy!A", handler.LastFilterPrefix);
}
+ ///
+ /// The reply payload's SnapshotTruncated comes from the handler's
+ /// verdict. This is the last hop before the IPC frame; if the executor
+ /// dropped it, a filtered query returning no records would carry no
+ /// completeness caveat at all.
+ ///
+ /// The verdict the fake handler reports.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void QueryActiveAlarms_StampsSnapshotTruncatedFromHandler(bool handlerReportsTruncated)
+ {
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ SnapshotTruncated = handlerReportsTruncated,
+ QueryResult = new[]
+ {
+ new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" },
+ },
+ };
+ MxAccessCommandExecutor executor = NewExecutor(handler);
+
+ StaCommand command = new StaCommand(
+ SessionId, CorrelationId,
+ new MxCommand
+ {
+ Kind = MxCommandKind.QueryActiveAlarms,
+ QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(),
+ });
+
+ MxCommandReply reply = executor.Execute(command);
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.NotNull(reply.QueryActiveAlarms);
+ Assert.Equal(handlerReportsTruncated, reply.QueryActiveAlarms.SnapshotTruncated);
+ }
+
+ ///
+ /// A truncated fetch whose records all filtered out still reports the
+ /// caveat on the payload — the case the per-record flag cannot cover.
+ ///
+ [Fact]
+ public void QueryActiveAlarms_WithTruncatedFetchAndNoRecords_StillReportsTruncation()
+ {
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ SnapshotTruncated = true,
+ QueryResult = Array.Empty(),
+ };
+ MxAccessCommandExecutor executor = NewExecutor(handler);
+
+ StaCommand command = new StaCommand(
+ SessionId, CorrelationId,
+ new MxCommand
+ {
+ Kind = MxCommandKind.QueryActiveAlarms,
+ QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(),
+ });
+
+ MxCommandReply reply = executor.Execute(command);
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.Empty(reply.QueryActiveAlarms.Snapshots);
+ Assert.True(reply.QueryActiveAlarms.SnapshotTruncated);
+ }
+
/// Verifies that unsubscribe routes to handler.
[Fact]
public void UnsubscribeAlarms_WithHandler_RoutesToHandler()
@@ -371,8 +437,8 @@ public sealed class AlarmCommandExecutorTests
/// Gets the last alarm filter prefix.
public string? LastFilterPrefix { get; private set; }
- /// Gets or sets the truncation verdict the executor stamps onto the reply payload.
- public bool LastSnapshotTruncated { get; set; }
+ /// Gets or sets the truncation verdict this handler reports with its query result.
+ public bool SnapshotTruncated { get; set; }
///
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
@@ -416,9 +482,12 @@ public sealed class AlarmCommandExecutorTests
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
///
- public IReadOnlyList QueryActive(string? alarmFilterPrefix)
+ public IReadOnlyList QueryActive(
+ string? alarmFilterPrefix,
+ out bool snapshotTruncated)
{
LastFilterPrefix = alarmFilterPrefix;
+ snapshotTruncated = SnapshotTruncated;
return QueryResult;
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs
index d44a07e..2b9e35e 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs
@@ -151,7 +151,7 @@ public sealed class AlarmCommandHandlerTests
() => consumer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
- IReadOnlyList snapshots = handler.QueryActive(null);
+ IReadOnlyList snapshots = handler.QueryActive(null, out _);
Assert.Single(snapshots);
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
@@ -175,12 +175,66 @@ public sealed class AlarmCommandHandlerTests
() => consumer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
- IReadOnlyList filtered = handler.QueryActive("Galaxy!AreaA");
+ IReadOnlyList filtered = handler.QueryActive("Galaxy!AreaA", out _);
Assert.Single(filtered);
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
}
+ ///
+ /// The consumer's truncation verdict reaches the caller through the
+ /// handler and its dispatcher. This is the middle hop of the flag's
+ /// journey to the QueryActiveAlarms reply payload; without it the reply
+ /// builder would have nothing to stamp.
+ ///
+ /// The verdict the fake consumer reports.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void QueryActive_ReportsConsumerTruncationVerdict(bool consumerReportsTruncated)
+ {
+ FakeConsumer consumer = new FakeConsumer
+ {
+ SnapshotTruncated = consumerReportsTruncated,
+ SnapshotResult = new[] { NewRecord("Galaxy", "AreaA", "Tag1") },
+ };
+ AlarmCommandHandler handler = new AlarmCommandHandler(
+ new MxAccessEventQueue(),
+ () => consumer);
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+
+ IReadOnlyList snapshots = handler.QueryActive(null, out bool snapshotTruncated);
+
+ Assert.Equal(consumerReportsTruncated, snapshotTruncated);
+ Assert.Equal(consumerReportsTruncated, Assert.Single(snapshots).FromTruncatedSnapshot);
+ }
+
+ ///
+ /// A prefix filter that removes every record must not remove the verdict
+ /// with them. This is exactly why the flag rides out separately as well as
+ /// on each record: a scoped query over a truncated fetch can legitimately
+ /// return nothing and still owe the caller the completeness caveat.
+ ///
+ [Fact]
+ public void QueryActive_WhenPrefixFiltersOutEveryRecord_StillReportsTruncation()
+ {
+ FakeConsumer consumer = new FakeConsumer
+ {
+ SnapshotTruncated = true,
+ SnapshotResult = new[] { NewRecord("Galaxy", "AreaB", "Tag2") },
+ };
+ AlarmCommandHandler handler = new AlarmCommandHandler(
+ new MxAccessEventQueue(),
+ () => consumer);
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+
+ IReadOnlyList filtered =
+ handler.QueryActive("Galaxy!AreaA", out bool snapshotTruncated);
+
+ Assert.Empty(filtered);
+ Assert.True(snapshotTruncated);
+ }
+
/// Verifies that dispose unsubscribes and disposes consumer when subscribed.
[Fact]
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
@@ -227,7 +281,7 @@ public sealed class AlarmCommandHandlerTests
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
Assert.Equal(3, guardInvocations);
- _ = handler.QueryActive(null);
+ _ = handler.QueryActive(null, out _);
Assert.Equal(4, guardInvocations);
handler.PollOnce();
@@ -268,7 +322,7 @@ public sealed class AlarmCommandHandlerTests
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
Assert.Throws(
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
- Assert.Throws(() => handler.QueryActive(null));
+ Assert.Throws(() => handler.QueryActive(null, out _));
Assert.Throws(() => handler.PollOnce());
Assert.Throws(() => handler.Unsubscribe());
}
@@ -470,11 +524,15 @@ public sealed class AlarmCommandHandlerTests
/// Gets the last acknowledge-by-name parameters.
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
- ///
- public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult;
+ /// Gets or sets the truncation verdict this consumer reports with its snapshot.
+ public bool SnapshotTruncated { get; set; }
- /// Gets or sets the truncation verdict reported for the last fetch.
- public bool LastSnapshotTruncated { get; set; }
+ ///
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
+ {
+ truncated = SnapshotTruncated;
+ return SnapshotResult;
+ }
/// Gets the number of times polled.
public int PollCount { get; private set; }
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
index 7b0474a..d41b42f 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
@@ -267,7 +267,7 @@ public sealed class AlarmDispatcherTests
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
- IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms();
+ IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count);
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
@@ -323,7 +323,7 @@ public sealed class AlarmDispatcherTests
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
- IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms();
+ IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count);
Assert.True(snapshots[0].Degraded);
@@ -333,6 +333,82 @@ public sealed class AlarmDispatcherTests
Assert.Equal(AlarmProviderMode.Alarmmgr, snapshots[1].SourceProvider);
}
+ ///
+ /// A truncated consumer snapshot stamps every mapped record with
+ /// FromTruncatedSnapshot and reports the verdict out of the same
+ /// call. Every record carries it because the public QueryActiveAlarms RPC
+ /// streams bare snapshots with no envelope to hold set-level status, so
+ /// a client that reads only one record must still learn the set may be
+ /// incomplete.
+ ///
+ [Fact]
+ public void SnapshotActiveAlarms_WhenConsumerReportsTruncated_StampsEveryRecord()
+ {
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer
+ {
+ SnapshotTruncated = true,
+ SnapshotResult = new[]
+ {
+ NewSnapshotRecord("Tag1", degraded: false),
+ NewSnapshotRecord("Tag2", degraded: true),
+ },
+ };
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(
+ consumer,
+ new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
+ SessionId);
+
+ IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
+
+ Assert.True(truncated);
+ Assert.Equal(2, snapshots.Count);
+ Assert.All(snapshots, snapshot => Assert.True(snapshot.FromTruncatedSnapshot));
+ // Truncation is about the SET; the per-record provider fidelity flag is
+ // independent and must not be dragged along with it.
+ Assert.False(snapshots[0].Degraded);
+ Assert.True(snapshots[1].Degraded);
+ }
+
+ ///
+ /// The control. A complete consumer snapshot must leave every record's
+ /// FromTruncatedSnapshot unset — without this, a field hard-wired
+ /// to true would satisfy the test above and every snapshot would read as
+ /// possibly-incomplete.
+ ///
+ [Fact]
+ public void SnapshotActiveAlarms_WhenConsumerReportsComplete_LeavesRecordsUnstamped()
+ {
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer
+ {
+ SnapshotTruncated = false,
+ SnapshotResult = new[] { NewSnapshotRecord("Tag1", degraded: false) },
+ };
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(
+ consumer,
+ new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
+ SessionId);
+
+ IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
+
+ Assert.False(truncated);
+ Assert.False(Assert.Single(snapshots).FromTruncatedSnapshot);
+ }
+
+ private static MxAlarmSnapshotRecord NewSnapshotRecord(string tagName, bool degraded)
+ {
+ return new MxAlarmSnapshotRecord
+ {
+ AlarmGuid = Guid.NewGuid(),
+ ProviderName = "Galaxy",
+ Group = "TestArea",
+ TagName = tagName,
+ Type = "DSC",
+ Priority = 500,
+ State = MxAlarmStateKind.UnackAlm,
+ Degraded = degraded,
+ };
+ }
+
/// Verifies that dispose unsubscribes the handler and disposes the consumer.
[Fact]
public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer()
@@ -432,15 +508,16 @@ public sealed class AlarmDispatcherTests
/// Gets the last acknowledge-by-name tuple (alarm name, provider, group).
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
+ /// Gets or sets the truncation verdict this consumer reports with its snapshot.
+ public bool SnapshotTruncated { get; set; }
+
///
- public IReadOnlyList SnapshotActiveAlarms()
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
{
+ truncated = SnapshotTruncated;
return SnapshotResult;
}
- /// Gets or sets the truncation verdict the dispatcher stamps onto snapshots.
- public bool LastSnapshotTruncated { get; set; }
-
/// Gets the count of poll operations.
public int PollCount { get; private set; }
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
index 3625ab9..cabc520 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
@@ -78,10 +78,14 @@ public sealed class FailoverAlarmConsumerTests
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11;
///
- public IReadOnlyList SnapshotActiveAlarms() => Array.Empty();
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
+ {
+ truncated = SnapshotTruncated;
+ return Array.Empty();
+ }
/// Gets or sets the truncation verdict this child reports, so delegation is observable.
- public bool LastSnapshotTruncated { get; set; }
+ public bool SnapshotTruncated { get; set; }
///
public void Dispose() { }
@@ -124,7 +128,7 @@ public sealed class FailoverAlarmConsumerTests
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22;
///
- public IReadOnlyList SnapshotActiveAlarms()
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
{
SnapshotCalls++;
if (ThrowOnSnapshot)
@@ -132,11 +136,12 @@ public sealed class FailoverAlarmConsumerTests
throw new InvalidOperationException("priming snapshot failed");
}
+ truncated = SnapshotTruncated;
return Array.Empty();
}
/// Gets or sets the truncation verdict this child reports, so delegation is observable.
- public bool LastSnapshotTruncated { get; set; }
+ public bool SnapshotTruncated { get; set; }
///
public void Dispose() { }
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
index 0e7fd07..062fb0f 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
@@ -643,9 +643,6 @@ public sealed class MxAccessStaSessionTests
get { lock (gate) return lastPollThreadId; }
}
- /// Gets or sets the truncation verdict reported for the last fetch.
- public bool LastSnapshotTruncated { get; set; }
-
///
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
@@ -671,8 +668,13 @@ public sealed class MxAccessStaSessionTests
=> 0;
///
- public IReadOnlyList QueryActive(string? alarmFilterPrefix)
- => Array.Empty();
+ public IReadOnlyList QueryActive(
+ string? alarmFilterPrefix,
+ out bool snapshotTruncated)
+ {
+ snapshotTruncated = false;
+ return Array.Empty();
+ }
///
public void PollOnce()
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
index 596ae9a..093a3b9 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
@@ -130,7 +130,7 @@ public sealed class SubtagAlarmConsumerTests
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
- IReadOnlyList snapshot = consumer.SnapshotActiveAlarms();
+ IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(out _);
Assert.Single(snapshot);
Assert.True(snapshot[0].Degraded);
@@ -150,7 +150,7 @@ public sealed class SubtagAlarmConsumerTests
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
- IReadOnlyList snapshot = consumer.SnapshotActiveAlarms();
+ IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(out _);
Assert.NotNull(emitted);
Assert.Single(snapshot);
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 28c4293..d1844f9 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs
@@ -691,23 +691,29 @@ public sealed class WnWrapAlarmConsumerXmlTests
// -------------------------------------------------------------------------
// Degraded-status signal. The truncation guard above keeps a capped fetch
// from broadcasting phantom Clears, but it does so silently: the retained
- // snapshot simply stops shrinking. LastSnapshotTruncated is what makes that
- // suppression visible to the QueryActiveAlarms reply and, through it, the
- // dashboard banner — so its set/reset behaviour is the contract, not detail.
+ // snapshot simply stops shrinking. The truncation verdict SnapshotActiveAlarms
+ // hands back alongside the records is what makes that suppression visible to
+ // the QueryActiveAlarms reply and, through it, the dashboard banner — so its
+ // set/reset behaviour is the contract, not detail.
+ //
+ // These assert through SnapshotActiveAlarms(out ...) rather than any internal
+ // field, because the pairing IS the contract: records and verdict must come
+ // out of one call, produced under one lock acquisition.
// -------------------------------------------------------------------------
///
- /// A capped fetch sets the retained truncation verdict. Without this the
- /// signal never leaves the consumer and the reply builder stamps a
- /// complete-looking snapshot over a capped one.
+ /// A capped fetch sets the truncation verdict handed out with the
+ /// snapshot. Without this the signal never leaves the consumer and the
+ /// reply builder stamps a complete-looking snapshot over a capped one.
///
[Fact]
- public void FoldFetch_WhenFetchTruncated_SetsLastSnapshotTruncated()
+ public void SnapshotActiveAlarms_AfterTruncatedFetch_ReportsTruncated()
{
const int Cap = 8;
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
- Assert.False(consumer.LastSnapshotTruncated);
+ consumer.SnapshotActiveAlarms(out bool truncatedBeforeAnyFetch);
+ Assert.False(truncatedBeforeAnyFetch);
Dictionary next =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount);
@@ -715,8 +721,14 @@ public sealed class WnWrapAlarmConsumerXmlTests
consumer.FoldFetch(next, truncated: true, out int retainedCount);
- Assert.True(consumer.LastSnapshotTruncated);
+ IReadOnlyList snapshot =
+ consumer.SnapshotActiveAlarms(out bool truncated);
+
+ Assert.True(truncated);
Assert.Equal(Cap, retainedCount);
+ // The verdict describes THIS set — assert they arrive together, not just
+ // that the boolean flipped somewhere.
+ Assert.Equal(Cap, snapshot.Count);
}
///
@@ -727,7 +739,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
/// it — the opposite of what the signal is for.
///
[Fact]
- public void FoldFetch_AfterTruncatedFetch_SubCapFetchClearsLastSnapshotTruncated()
+ public void SnapshotActiveAlarms_AfterSubCapFetchFollowingTruncation_ReportsComplete()
{
const int Cap = 8;
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
@@ -735,7 +747,8 @@ public sealed class WnWrapAlarmConsumerXmlTests
Dictionary capped =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
consumer.FoldFetch(capped, truncated: true, out _);
- Assert.True(consumer.LastSnapshotTruncated);
+ consumer.SnapshotActiveAlarms(out bool truncatedAfterCappedFetch);
+ Assert.True(truncatedAfterCappedFetch);
Dictionary complete =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount);
@@ -743,11 +756,15 @@ public sealed class WnWrapAlarmConsumerXmlTests
consumer.FoldFetch(complete, truncated: false, out int retainedCount);
- Assert.False(consumer.LastSnapshotTruncated);
+ IReadOnlyList snapshot =
+ consumer.SnapshotActiveAlarms(out bool truncated);
+
+ Assert.False(truncated);
// The complete fetch also replaced the snapshot wholesale, which is what
// makes it authoritative about absence — pinned here so a future change
// cannot clear the verdict while keeping the merge semantics.
Assert.Equal(Cap - 1, retainedCount);
+ Assert.Equal(Cap - 1, snapshot.Count);
}
///
@@ -756,7 +773,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
/// still see the caveat.
///
[Fact]
- public void FoldFetch_WithConsecutiveTruncatedFetches_KeepsLastSnapshotTruncatedSet()
+ public void SnapshotActiveAlarms_WithConsecutiveTruncatedFetches_KeepsReportingTruncated()
{
const int Cap = 8;
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
@@ -766,7 +783,9 @@ public sealed class WnWrapAlarmConsumerXmlTests
Dictionary capped =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
consumer.FoldFetch(capped, truncated: true, out _);
- Assert.True(consumer.LastSnapshotTruncated);
+
+ consumer.SnapshotActiveAlarms(out bool truncated);
+ Assert.True(truncated);
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
index aa3857c..9fb9254 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
@@ -368,7 +368,7 @@ public sealed class AlarmSubtagLiveSmokeTests
raiseEvent.Record.AlarmGuid, raiseEvent.Record.Degraded, raiseEvent.Record.State));
// 2. Snapshot active alarms and confirm the raised alarm is present.
- IReadOnlyList snapshot = consumer.SnapshotActiveAlarms();
+ IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(out _);
Log(string.Format("SnapshotActiveAlarms count={0}", snapshot.Count));
foreach (MxAlarmSnapshotRecord s in snapshot)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs
index c92b794..181add7 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs
@@ -121,7 +121,7 @@ public sealed class AlarmsLiveSmokeTests
Assert.Contains("Galaxy", raiseBody.AlarmFullReference);
// 2. Snapshot the active set + verify the captured alarm is there.
- var snapshot = dispatcher.SnapshotActiveAlarms();
+ var snapshot = dispatcher.SnapshotActiveAlarms(out _);
Log($"SnapshotActiveAlarms count={snapshot.Count}");
foreach (var s in snapshot)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs
index 534b9fa..f2b5381 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs
@@ -325,11 +325,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
}
///
- public IReadOnlyList QueryActive(string? alarmFilterPrefix)
+ public IReadOnlyList QueryActive(
+ string? alarmFilterPrefix,
+ out bool snapshotTruncated)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
- IReadOnlyList all = d.SnapshotActiveAlarms();
+ // The verdict rides out of the same call that produced the records, so
+ // filtering below cannot separate it from the set it describes.
+ IReadOnlyList all = d.SnapshotActiveAlarms(out snapshotTruncated);
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
List filtered = new List(all.Count);
foreach (ActiveAlarmSnapshot snap in all)
@@ -342,25 +346,6 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
return filtered;
}
- ///
- ///
- /// Deliberately does not go through GetDispatcherOrThrow: an
- /// unsubscribed handler has performed no fetch, and "no fetch" is not
- /// truncated. Throwing here would turn a status read into a command
- /// failure on a path the reply builder takes after the snapshot has
- /// already been produced.
- ///
- public bool LastSnapshotTruncated
- {
- get
- {
- if (disposed) return false;
- AlarmDispatcher? d;
- lock (syncRoot) d = dispatcher;
- return d is not null && d.LastSnapshotTruncated;
- }
- }
-
///
public void PollOnce()
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs
index c5906c9..e9f4012 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs
@@ -154,15 +154,24 @@ public sealed class AlarmDispatcher : IDisposable
/// protos for the
/// QueryActiveAlarms RPC's ConditionRefresh stream.
///
+ ///
+ /// Receives whether the fetch behind the snapshot hit the per-fetch cap,
+ /// so the returned set may omit active alarms. Forwarded from the single
+ /// atomic consumer read, and stamped onto every returned record as
+ /// ActiveAlarmSnapshot.FromTruncatedSnapshot. Also returned
+ /// separately because a snapshot of zero records still has to report it.
+ ///
/// The currently active alarm snapshots.
- public IReadOnlyList SnapshotActiveAlarms()
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
- // Read the truncation verdict before the snapshot, so a poll landing
- // between the two can only widen the warning (a stale "truncated" over a
- // complete snapshot), never narrow it into a false all-clear.
- bool truncated = consumer.LastSnapshotTruncated;
- IReadOnlyList records = consumer.SnapshotActiveAlarms();
+ // One consumer call yields the records and the verdict together, under a
+ // single acquisition of the consumer's snapshot lock. Reading them
+ // separately would let a poll interleave and pair a stale not-truncated
+ // verdict with a capped snapshot — a set that reads complete while
+ // missing actives. The atomicity is structural here, not a consequence of
+ // the STA happening to serialize the two calls.
+ IReadOnlyList records = consumer.SnapshotActiveAlarms(out truncated);
if (records.Count == 0) return Array.Empty();
List snapshots = new List(records.Count);
foreach (MxAlarmSnapshotRecord record in records)
@@ -172,14 +181,6 @@ public sealed class AlarmDispatcher : IDisposable
return snapshots;
}
- ///
- /// Whether the consumer's most recent fetch hit the per-fetch cap, so
- /// the set returns may omit actives.
- /// Stamped onto the QueryActiveAlarms reply payload, which is the only
- /// carrier when the snapshot filters down to zero records.
- ///
- public bool LastSnapshotTruncated => !disposed && consumer.LastSnapshotTruncated;
-
private void OnTransition(object? sender, MxAlarmTransitionEvent transition)
{
if (disposed) return;
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs
index 9c69645..967cac3 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs
@@ -260,23 +260,18 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
}
///
- public IReadOnlyList SnapshotActiveAlarms()
+ ///
+ /// Both values come from ONE call to the active child, so the snapshot
+ /// and its verdict cannot end up describing different children across a
+ /// failover. A failover to the subtag standby therefore reports
+ /// not-truncated — correctly, since that child performs no capped fetch.
+ ///
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
{
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
- return ActiveChild.SnapshotActiveAlarms();
+ return ActiveChild.SnapshotActiveAlarms(out truncated);
}
- ///
- ///
- /// Delegated to the active child, matching
- /// : the flag describes the snapshot
- /// the same child produced, so reading it off the standby would pair a
- /// verdict with a snapshot it does not belong to. A failover to the
- /// subtag standby therefore reports not-truncated — correctly, since
- /// that child performs no capped fetch.
- ///
- public bool LastSnapshotTruncated => !disposed && ActiveChild.LastSnapshotTruncated;
-
private IMxAccessAlarmConsumer ActiveChild => active == Active.Primary ? primary : standby;
///
@@ -351,7 +346,7 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
{
try
{
- _ = standby.SnapshotActiveAlarms();
+ _ = standby.SnapshotActiveAlarms(out _);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs
index bd8b6a9..638f84f 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs
@@ -71,18 +71,17 @@ public interface IAlarmCommandHandler : IDisposable
/// prefix matched against AlarmFullReference.
///
/// Optional prefix to filter alarms by.
+ ///
+ /// Receives whether the fetch behind the snapshot hit the per-fetch cap,
+ /// so the set may omit active alarms. Carried out alongside the records
+ /// rather than read from a separate property, both so the pair comes from
+ /// one atomic consumer read and because it is the only carrier left once
+ /// (or an empty galaxy) filters the
+ /// records down to none. when there is no active
+ /// subscription: no fetch has happened, so nothing is capped.
+ ///
/// The currently active alarms matching the filter.
- IReadOnlyList QueryActive(string? alarmFilterPrefix);
-
- ///
- /// Whether the consumer's most recent fetch hit the per-fetch cap, so
- /// the set draws from may omit active alarms.
- /// Stamped on the QueryActiveAlarms reply payload — the only carrier
- /// once a prefix filter (or an empty galaxy) leaves zero records to
- /// carry the per-record flag. when there is no
- /// active subscription: no fetch has happened, so nothing is capped.
- ///
- bool LastSnapshotTruncated { get; }
+ IReadOnlyList QueryActive(string? alarmFilterPrefix, out bool snapshotTruncated);
///
/// Drives a single poll of the underlying alarm consumer on the
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs
index 71f30e8..d5b2b14 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs
@@ -36,20 +36,6 @@ public interface IMxAccessAlarmConsumer : IDisposable
///
event EventHandler? AlarmTransitionEmitted;
- ///
- /// Whether the most recent fetch that reached the retained snapshot came
- /// back holding the per-fetch cap. While this is
- /// the snapshot returned by is
- /// authoritative about presence only: the provider may hold actives it
- /// had no room to report, and the consumer has suspended the
- /// absence-implies-Clear inference. Not latched — the first sub-cap fetch
- /// after a run of capped ones clears it, because that fetch is complete
- /// and the snapshot it produced is again authoritative about absence.
- /// Consumers with no per-fetch cap (the subtag fallback, which is
- /// event-driven) always report .
- ///
- bool LastSnapshotTruncated { get; }
-
///
/// Initializes the AVEVA alarm-client connection, registers as a
/// consumer, and subscribes to the supplied alarm-provider expression.
@@ -111,12 +97,43 @@ public interface IMxAccessAlarmConsumer : IDisposable
///
/// Returns the consumer's most recently parsed snapshot of currently
- /// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7)
+ /// active alarms, together with whether the fetch that produced it hit
+ /// the per-fetch cap. Used by the gateway's QueryActiveAlarms (PR A.7)
/// ConditionRefresh path — operator clients call this after reconnect
/// to seed local Part 9 state.
///
+ ///
+ ///
+ /// The verdict is an out parameter rather than a separate
+ /// property on purpose, and the reason is a correctness one.
+ /// Implementations must produce both values from a single acquisition
+ /// of whatever lock guards the retained snapshot, mirroring the write
+ /// side (WnWrapAlarmConsumer.FoldFetch updates snapshot and
+ /// verdict together). Two separate reads could straddle a poll that
+ /// flips not-truncated → truncated and pair a stale
+ /// with a capped snapshot — a snapshot that
+ /// reads as complete while missing actives, which is exactly the
+ /// false all-clear this signal exists to prevent. Making the pair
+ /// inseparable in the signature removes the possibility rather than
+ /// relying on callers, or on the STA serializing them.
+ ///
+ ///
+ /// While is the
+ /// returned snapshot is authoritative about presence only: the
+ /// provider may hold actives it had no room to report, and the
+ /// consumer has suspended the absence-implies-Clear inference. Not
+ /// latched — the first sub-cap fetch clears it, because that fetch is
+ /// complete and its snapshot is again authoritative about absence.
+ /// Consumers with no per-fetch cap (the subtag fallback, which is
+ /// advise-driven) always report .
+ ///
+ ///
+ ///
+ /// Receives whether the fetch behind the returned snapshot hit the
+ /// per-fetch cap.
+ ///
/// The most recently parsed snapshot of currently active alarms.
- IReadOnlyList SnapshotActiveAlarms();
+ IReadOnlyList SnapshotActiveAlarms(out bool truncated);
///
/// Drives a single synchronous poll of the underlying alarm source.
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs
index f1ada4b..62b4e94 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs
@@ -974,13 +974,15 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
try
{
IReadOnlyList snapshots = alarmCommandHandler.QueryActive(
- command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix);
+ command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix,
+ out bool snapshotTruncated);
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
payload.Snapshots.AddRange(snapshots);
// Set-level degraded status: the snapshot may omit actives because
// the provider fetch hit its cap. The records carry the same flag,
// but a prefix filter can leave none, so the payload states it too.
- payload.SnapshotTruncated = alarmCommandHandler.LastSnapshotTruncated;
+ // Same value the records were stamped with — one read, not a second.
+ payload.SnapshotTruncated = snapshotTruncated;
MxCommandReply reply = CreateOkReply(command);
reply.QueryActiveAlarms = payload;
return reply;
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
index 754a36d..0d0292c 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
@@ -45,16 +45,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
/// Fires once per synthesized alarm-state transition.
public event EventHandler? AlarmTransitionEmitted;
- ///
- ///
- /// Always . Subtag mode is advise-driven over a
- /// fixed watch list — there is no bulk fetch and therefore no per-fetch
- /// cap to hit. Subtag snapshots are lower-fidelity in other ways, which
- /// MxAlarmSnapshotRecord.Degraded already reports; truncation is
- /// not one of them.
- ///
- public bool LastSnapshotTruncated => false;
-
///
/// Initializes the consumer over a subtag source and a watch list of
/// alarm targets.
@@ -162,8 +152,16 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
}
///
- public IReadOnlyList SnapshotActiveAlarms()
+ ///
+ /// is always : subtag
+ /// mode is advise-driven over a fixed watch list, so there is no bulk
+ /// fetch and no per-fetch cap to hit. Subtag snapshots are lower-fidelity
+ /// in other ways, which MxAlarmSnapshotRecord.Degraded already
+ /// reports; truncation is not one of them.
+ ///
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
{
+ truncated = false;
IReadOnlyList records = stateMachine.SnapshotActive();
foreach (MxAlarmSnapshotRecord record in records)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs
index 0f07764..8280e13 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs
@@ -128,8 +128,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
///
/// COM-free construction, for exercising the retained-snapshot state
- /// machine ( /
- /// / ) on a machine without AVEVA
+ /// machine ( / )
+ /// on a machine without AVEVA
/// installed. throws and
/// no-ops on an instance built this way — both need the wnwrap coclass,
/// which cannot be instantiated on the macOS/Linux test matrix. Internal
@@ -372,8 +372,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// . 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.
+ /// The snapshot and are produced under one
+ /// syncRoot acquisition, the same one
+ /// writes them both under, so no poll can interleave between them.
///
- public IReadOnlyList SnapshotActiveAlarms()
+ public IReadOnlyList SnapshotActiveAlarms(out bool truncated)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
lock (syncRoot)
@@ -387,22 +390,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
active.Add(record);
}
}
+ truncated = lastSnapshotTruncated;
return active;
}
}
- ///
- ///
- /// Read without the disposed guard
- /// carries: this is degraded-status metadata a reply builder stamps
- /// alongside a snapshot, and throwing from it would fail a query whose
- /// snapshot half succeeded.
- ///
- public bool LastSnapshotTruncated
- {
- get { lock (syncRoot) { return lastSnapshotTruncated; } }
- }
-
///
/// Sink for the rate-limited truncated-fetch warning. Defaults to
/// , the stream
@@ -463,10 +455,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
///
/// Folds one fetch into the retained state under a single lock: the
/// transition diff, the snapshot merge/replace, and the truncation
- /// verdict move together. Splitting them would let a concurrent
- /// /
- /// pair read a capped snapshot alongside the previous poll's "complete"
- /// verdict — precisely the false all-clear the signal exists to prevent.
+ /// verdict move together. This is the write half of the pairing
+ /// reads; splitting either half would
+ /// let a reader see a capped snapshot alongside the previous poll's
+ /// "complete" verdict — precisely the false all-clear the signal exists
+ /// to prevent.
/// The verdict is replaced, never latched: a sub-cap fetch is complete
/// and restores absence authority, so leaving the flag set would strand
/// the operator banner on after a single burst.