From b9fb0dd720b500f3cd9c1514ac37a485ad642469 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 04:39:33 -0400 Subject: [PATCH] fix(alarms): atomic snapshot+truncation read; direct tests for the flag plumbing (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the truncation verdict through two independent lock acquisitions, defended by a comment claiming read-order made a race "widen only, never narrow". That claim was false: a not-truncated -> truncated poll landing between the two reads pairs a stale false with a capped snapshot, which is exactly the false all-clear the feature exists to prevent. It was safe only because AlarmCommandHandler STA-serializes consumer calls — an accident of the call graph, not an invariant. Made the invariant structural instead of documented. IMxAccessAlarmConsumer now exposes ONE accessor, `IReadOnlyList SnapshotActiveAlarms( out bool truncated)`, which implementations must satisfy from a single acquisition of the lock guarding the retained snapshot — mirroring the write side, where FoldFetch already updates snapshot and verdict together. The separate LastSnapshotTruncated property is gone from every layer, so there is no second read left to pair badly. `out` over a result struct follows the file's established idiom (FoldFetch, ParseSnapshotXml). The same threading applies one level up: IAlarmCommandHandler.QueryActive now carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply payload from the value the records were stamped with rather than reading the state a second time. Direct tests for the three hops that were only covered end-to-end: - AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot on every mapped record, with a complete-snapshot control, plus an assertion that the independent per-record Degraded flag is not dragged along. - AlarmCommandHandlerTests: the verdict delegates through the dispatcher (Theory over both values), and survives a prefix filter that removes every record — the case the per-record flag cannot cover. - AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the handler (Theory over both values), including the zero-record case. The WnWrapAlarmConsumer truncation tests now assert through SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing is the contract. Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last full reconcile" rather than implying it tracks the current _alarms contents, which live transitions keep moving via ApplyTransition between passes. Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch); no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway metadata about our fetch mechanics, not MXAccess behavior — not a parity deviation, and no event is synthesized. Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass. Worker + Worker.Tests are windev-gated; the signature change was reviewed by inspection across all 7 IMxAccessAlarmConsumer implementers, all 3 IAlarmCommandHandler implementers, and every call site. --- .../Alarms/GatewayAlarmMonitor.cs | 6 +- .../MxAccess/AlarmCommandExecutorTests.cs | 75 +++++++++++++++- .../MxAccess/AlarmCommandHandlerTests.cs | 74 +++++++++++++-- .../MxAccess/AlarmDispatcherTests.cs | 89 +++++++++++++++++-- .../MxAccess/FailoverAlarmConsumerTests.cs | 13 ++- .../MxAccess/MxAccessStaSessionTests.cs | 12 +-- .../MxAccess/SubtagAlarmConsumerTests.cs | 4 +- .../MxAccess/WnWrapAlarmConsumerXmlTests.cs | 47 +++++++--- .../Probes/AlarmSubtagLiveSmokeTests.cs | 2 +- .../Probes/AlarmsLiveSmokeTests.cs | 2 +- .../MxAccess/AlarmCommandHandler.cs | 27 ++---- .../MxAccess/AlarmDispatcher.cs | 29 +++--- .../MxAccess/FailoverAlarmConsumer.cs | 23 ++--- .../MxAccess/IAlarmCommandHandler.cs | 21 +++-- .../MxAccess/IMxAccessAlarmConsumer.cs | 49 ++++++---- .../MxAccess/MxAccessCommandExecutor.cs | 6 +- .../MxAccess/SubtagAlarmConsumer.cs | 20 ++--- .../MxAccess/WnWrapAlarmConsumer.cs | 31 +++---- 18 files changed, 376 insertions(+), 154 deletions(-) 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.