From e7660134f2ea7dcaf192b5725fea8ca022b070db Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 15:44:28 -0400 Subject: [PATCH] fix(communication): drop IsConfiguredPlaceholder rows in StreamRelayActor before gRPC pack Placeholder AlarmStateChanged rows are a DebugView snapshot-only concept emitted by InstanceActor.BuildAlarmStatesSnapshot; they are never a real alarm transition. Their timestamp may be DateTimeOffset.MinValue (the Protobuf Timestamp lower boundary), which can throw when packed via Timestamp.FromDateTimeOffset. Added early-return guard at the top of HandleAlarmStateChanged before any timestamp pack or channel write. Updated the existing NativeBindingLinkage round-trip test to use a real (non-placeholder) native alarm; added DropsAlarmStateChanged_WhenIsConfiguredPlaceholder to assert placeholders are silently dropped (15/15 pass). --- .../Actors/StreamRelayActor.cs | 9 ++++ .../Grpc/StreamRelayActorTests.cs | 45 ++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs index ffe6c6a0..0546296e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs @@ -56,6 +56,15 @@ public class StreamRelayActor : ReceiveActor private void HandleAlarmStateChanged(AlarmStateChanged msg) { + // Placeholder rows (IsConfiguredPlaceholder) are a Debug View snapshot-only + // concept emitted by InstanceActor.BuildAlarmStatesSnapshot — they are never a + // real alarm transition and must not be relayed to the live gRPC stream (their + // timestamp may be DateTimeOffset.MinValue, the Protobuf Timestamp boundary). + if (msg.IsConfiguredPlaceholder) + { + return; + } + var protoEvent = new SiteStreamEvent { CorrelationId = _correlationId, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs index cbfeab58..c7aa7822 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs @@ -116,9 +116,9 @@ public class StreamRelayActorTests : TestKit [Fact] public void RelaysAlarmStateChanged_NativeBindingLinkage_SurvivesFullRoundTrip() { - // DV-1: the native source-binding canonical name and the configured-placeholder - // flag must pack into AlarmStateUpdate (StreamRelayActor) and unpack back out - // (SiteStreamGrpcClient.ConvertToDomainEvent) — the full cross-process round trip. + // DV-1: the native source-binding canonical name must pack into AlarmStateUpdate + // (StreamRelayActor) and unpack back out (SiteStreamGrpcClient.ConvertToDomainEvent) + // — the full cross-process round trip for a real (non-placeholder) native alarm. var channel = Channel.CreateUnbounded(); var actor = Sys.ActorOf(Props.Create(() => new StreamRelayActor("corr-native-binding", channel.Writer))); @@ -130,7 +130,7 @@ public class StreamRelayActorTests : TestKit Kind = AlarmKind.NativeOpcUa, SourceReference = "Motor1.MotorAlarms.Hi", NativeSourceCanonicalName = "Motor1.MotorAlarms", - IsConfiguredPlaceholder = true, + IsConfiguredPlaceholder = false, Condition = new AlarmConditionState( Active: true, Acknowledged: false, Confirmed: null, Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900) @@ -150,13 +150,46 @@ public class StreamRelayActorTests : TestKit // Packed onto the wire frame by StreamRelayActor. Assert.Equal("Motor1.MotorAlarms", protoEvent.AlarmChanged.NativeSourceCanonicalName); - Assert.True(protoEvent.AlarmChanged.IsConfiguredPlaceholder); + Assert.False(protoEvent.AlarmChanged.IsConfiguredPlaceholder); // Unpacked back into the domain record by SiteStreamGrpcClient. var roundTripped = Assert.IsType( SiteStreamGrpcClient.ConvertToDomainEvent(protoEvent)); Assert.Equal("Motor1.MotorAlarms", roundTripped.NativeSourceCanonicalName); - Assert.True(roundTripped.IsConfiguredPlaceholder); + Assert.False(roundTripped.IsConfiguredPlaceholder); + } + + [Fact] + public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder() + { + // Placeholder rows are a Debug View snapshot-only concept emitted by + // InstanceActor.BuildAlarmStatesSnapshot. They are never a real alarm + // transition; their timestamp may be DateTimeOffset.MinValue (the Protobuf + // Timestamp boundary). The relay must drop them without writing to the channel. + var channel = Channel.CreateUnbounded(); + var actor = Sys.ActorOf(Props.Create(() => + new StreamRelayActor("corr-placeholder-drop", channel.Writer))); + + var domainEvent = new AlarmStateChanged( + "Site1.Tank01", "Tank1.Levels.Hi", AlarmState.Normal, 500, + DateTimeOffset.MinValue) + { + Kind = AlarmKind.NativeOpcUa, + NativeSourceCanonicalName = "Tank1.Levels", + IsConfiguredPlaceholder = true, + Condition = new AlarmConditionState( + Active: false, Acknowledged: false, Confirmed: null, + Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 0) + }; + + actor.Tell(domainEvent); + + // Allow time for the actor to process the message. + Thread.Sleep(500); + + // Nothing must have been written — placeholder is silently dropped. + Assert.False(channel.Reader.TryRead(out _), + "Placeholder AlarmStateChanged must not be relayed to the gRPC stream"); } [Fact]