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.